base ui
This commit is contained in:
parent
670fcc3303
commit
c79562ecf2
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.maronato.dev/maronato/goshort/cmd/shared"
|
"git.maronato.dev/maronato/goshort/cmd/shared"
|
||||||
|
@ -92,7 +93,10 @@ func serveAPI(ctx context.Context, cfg *config.Config) error {
|
||||||
// Configure app routes
|
// Configure app routes
|
||||||
server.Mux.Route("/api", func(r chi.Router) {
|
server.Mux.Route("/api", func(r chi.Router) {
|
||||||
// Set CORS headers for API routes in development mode
|
// Set CORS headers for API routes in development mode
|
||||||
r.Use(middleware.SetHeader("Access-Control-Allow-Origin", "*"))
|
r.Use(middleware.SetHeader("Access-Control-Allow-Origin", "http://"+net.JoinHostPort(cfg.Host, cfg.UIPort)))
|
||||||
|
r.Use(middleware.SetHeader("Access-Control-Allow-Methods", "*"))
|
||||||
|
r.Use(middleware.SetHeader("Access-Control-Allow-Headers", "*"))
|
||||||
|
r.Use(middleware.SetHeader("Access-Control-Allow-Credentials", "true"))
|
||||||
r.Use(servermiddleware.Auth(userService))
|
r.Use(servermiddleware.Auth(userService))
|
||||||
r.Mount("/", apiRouter)
|
r.Mount("/", apiRouter)
|
||||||
})
|
})
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"ignorePatterns": [
|
||||||
|
"**/*.js"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2021": true,
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": "latest"
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"@marolint/eslint-config-react"
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
"@marolint/eslint-config-react/prettier.config"
|
File diff suppressed because it is too large
Load Diff
|
@ -10,19 +10,22 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"classnames": "^2.3.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@marolint/eslint-config-react": "^1.0.2",
|
||||||
"@types/react": "^18.2.15",
|
"@types/react": "^18.2.15",
|
||||||
"@types/react-dom": "^18.2.7",
|
"@types/react-dom": "^18.2.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
|
||||||
"@typescript-eslint/parser": "^6.0.0",
|
|
||||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||||
"eslint": "^8.45.0",
|
"autoprefixer": "^10.4.15",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint": "^8.47.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.3",
|
"postcss": "^8.4.28",
|
||||||
"typescript": "^5.0.2",
|
"prettier": "^2.8.8",
|
||||||
|
"tailwindcss": "^3.3.3",
|
||||||
|
"typescript": "^5.1.6",
|
||||||
"vite": "^4.4.5"
|
"vite": "^4.4.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
|
@ -2,41 +2,4 @@
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 6em;
|
|
||||||
padding: 1.5em;
|
|
||||||
will-change: filter;
|
|
||||||
transition: filter 300ms;
|
|
||||||
}
|
|
||||||
.logo:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #646cffaa);
|
|
||||||
}
|
|
||||||
.logo.react:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes logo-spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
a:nth-of-type(2) .logo {
|
|
||||||
animation: logo-spin infinite 20s linear;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
padding: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.read-the-docs {
|
|
||||||
color: #888;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,41 +1,38 @@
|
||||||
import { useState } from 'react'
|
import { FunctionComponent } from "react"
|
||||||
import reactLogo from './assets/react.svg'
|
|
||||||
import viteLogo from '/vite.svg'
|
|
||||||
import './App.css'
|
|
||||||
|
|
||||||
function App() {
|
import { Outlet } from "react-router-dom"
|
||||||
const [count, setCount] = useState(0)
|
|
||||||
|
|
||||||
const incrCount = () => {
|
import "./App.css"
|
||||||
setCount(count + 1)
|
import Button from "./components/Button"
|
||||||
fetch(import.meta.env.VITE_API_URL).then((res) => {
|
import { useIsAuthenticated } from "./hooks/useAuth"
|
||||||
console.log(res)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const App: FunctionComponent = () => {
|
||||||
|
const isAuthenticated = useIsAuthenticated()
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col">
|
||||||
<div>
|
<div className="grid grid-cols-3 justify-between text-slate-500 font-medium">
|
||||||
<a href="https://vitejs.dev" target="_blank">
|
<div className="flex-flex-row mr-auto text-lg">
|
||||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
<Button text="GoShort" link="/" />
|
||||||
</a>
|
</div>
|
||||||
<a href="https://react.dev" target="_blank">
|
<div className="flex flex-row mx-auto">
|
||||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
{isAuthenticated && (
|
||||||
</a>
|
<>
|
||||||
|
<Button text="Shorts" link="shorts" />
|
||||||
|
<Button text="Tokens" link="tokens" />
|
||||||
|
<Button text="Sesions" link="sessions" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row ml-auto">
|
||||||
|
{isAuthenticated || <Button text="Signup" link="signup" />}
|
||||||
|
{isAuthenticated || <Button text="Login" link="login" />}
|
||||||
|
{isAuthenticated && <Button text="Logout" link="logout" />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1>Vite + React</h1>
|
<div className="flex flex-col">
|
||||||
<div className="card">
|
<Outlet />
|
||||||
<button onClick={incrCount}>
|
|
||||||
count is {count}
|
|
||||||
</button>
|
|
||||||
<p>
|
|
||||||
Edit <code>src/App.tsx</code> and save to test HMR
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="read-the-docs">
|
</div>
|
||||||
Click on the Vite and React logos to learn more
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { FunctionComponent } from "react"
|
||||||
|
|
||||||
|
import classNames from "classnames"
|
||||||
|
import { NavLink, useSearchParams } from "react-router-dom"
|
||||||
|
|
||||||
|
const Button: FunctionComponent<{
|
||||||
|
text: string
|
||||||
|
onClick?: () => void
|
||||||
|
link?: string
|
||||||
|
}> = ({ text, onClick, link }) => {
|
||||||
|
const Content: FunctionComponent<{ active?: boolean }> = ({ active }) => (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"px-0 mx-3 py-2 group border-b hover:border-blue-600 transition-colors duration-200",
|
||||||
|
{ "border-b-2 border-blue-600": active, "border-transparent": !active }
|
||||||
|
)}>
|
||||||
|
<button
|
||||||
|
className={classNames("group-hover:text-blue-600", {
|
||||||
|
"text-blue-600": active,
|
||||||
|
})}
|
||||||
|
onClick={onClick}>
|
||||||
|
{text}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Since a user may go from the login page to the signup page, we want to
|
||||||
|
// preserve the `from` query parameter so that we can redirect the user back
|
||||||
|
// to the page they were on before they logged in.
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const from = searchParams.get("from")
|
||||||
|
|
||||||
|
if (link) {
|
||||||
|
const linkWithFrom = from ? `${link}?from=${from}` : link
|
||||||
|
return (
|
||||||
|
<NavLink to={linkWithFrom}>
|
||||||
|
{({ isActive }) => <Content active={isActive} />}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <Content />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Button
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { LoaderFunction, redirect, useRouteLoaderData } from "react-router-dom"
|
||||||
|
|
||||||
|
import { User } from "../types"
|
||||||
|
import fetchAPI from "../util/fetchAPI"
|
||||||
|
|
||||||
|
export type AuthProviderType = {
|
||||||
|
user: User | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
signup(username: string, password: string): Promise<boolean>
|
||||||
|
login(username: string, password: string): Promise<boolean>
|
||||||
|
logout(): Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthProvider: AuthProviderType = {
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
async signup(username, password) {
|
||||||
|
const response = await fetchAPI("/signup", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return this.login(username, password)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
async login(username, password) {
|
||||||
|
const response = await fetchAPI<User>("/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
this.user = response.data
|
||||||
|
this.isAuthenticated = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
async logout() {
|
||||||
|
const response = await fetchAPI("/logout", {
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
this.user = null
|
||||||
|
this.isAuthenticated = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type IndexLoaderData = Pick<AuthProviderType, "user" | "isAuthenticated">
|
||||||
|
|
||||||
|
export const useUser = () =>
|
||||||
|
(useRouteLoaderData("root") as IndexLoaderData | null)?.user ?? null
|
||||||
|
|
||||||
|
export const useIsAuthenticated = () =>
|
||||||
|
(useRouteLoaderData("root") as IndexLoaderData | null)?.isAuthenticated ??
|
||||||
|
false
|
||||||
|
|
||||||
|
export const indexLoader: LoaderFunction =
|
||||||
|
async (): Promise<IndexLoaderData> => {
|
||||||
|
const response = await fetchAPI<User>("/me")
|
||||||
|
if (response.ok) {
|
||||||
|
AuthProvider.user = response.data
|
||||||
|
AuthProvider.isAuthenticated = true
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
user: AuthProvider.user,
|
||||||
|
isAuthenticated: AuthProvider.isAuthenticated,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loginAction: LoaderFunction = async ({ request }) => {
|
||||||
|
const formData = await request.formData()
|
||||||
|
const username = formData.get("username") as string | null
|
||||||
|
const password = formData.get("password") as string | null
|
||||||
|
|
||||||
|
// Validate our form inputs and return validation errors via useActionData()
|
||||||
|
if (!username || !password) {
|
||||||
|
return {
|
||||||
|
error: "You must provide a username and password to log in",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await AuthProvider.login(username, password)
|
||||||
|
// Sign in and redirect to the proper destination if successful.
|
||||||
|
if (!ok) {
|
||||||
|
return {
|
||||||
|
error: "Invalid login attempt",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectTo = formData.get("redirectTo") as string | null
|
||||||
|
return redirect(redirectTo || "/")
|
||||||
|
}
|
||||||
|
export const loginLoader: LoaderFunction = async () => {
|
||||||
|
if (AuthProvider.isAuthenticated) {
|
||||||
|
return redirect("/")
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const singupAction: LoaderFunction = async ({ request }) => {
|
||||||
|
const formData = await request.formData()
|
||||||
|
const username = formData.get("username") as string | null
|
||||||
|
const password = formData.get("password") as string | null
|
||||||
|
|
||||||
|
// Validate our form inputs and return validation errors via useActionData()
|
||||||
|
if (!username || !password) {
|
||||||
|
return {
|
||||||
|
error: "You must provide a username and password to sign up",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await AuthProvider.signup(username, password)
|
||||||
|
// Sign in and redirect to the proper destination if successful.
|
||||||
|
if (!ok) {
|
||||||
|
return {
|
||||||
|
error: "Invalid signup attempt",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectTo = formData.get("redirectTo") as string | null
|
||||||
|
return redirect(redirectTo || "/")
|
||||||
|
}
|
||||||
|
export const signupLoader = loginLoader
|
||||||
|
|
||||||
|
export const logoutLoader: LoaderFunction = async () => {
|
||||||
|
await AuthProvider.logout()
|
||||||
|
|
||||||
|
return redirect("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
export const protectedLoader: LoaderFunction = async ({ request }) => {
|
||||||
|
// If the user is not logged in and tries to access `/protected`, we redirect
|
||||||
|
// them to `/login` with a `from` parameter that allows login to redirect back
|
||||||
|
// to this page upon successful authentication
|
||||||
|
if (!AuthProvider.isAuthenticated) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set("from", new URL(request.url).pathname)
|
||||||
|
return redirect("/login?" + params.toString())
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
|
@ -1,69 +1,7 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
font-family: -apple-system, SF Pro Text, SF UI Text, system-ui, Helvetica Neue, Helvetica, Arial, sans-serif;
|
||||||
line-height: 1.5;
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
color-scheme: light dark;
|
|
||||||
color: rgba(255, 255, 255, 0.87);
|
|
||||||
background-color: #242424;
|
|
||||||
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #646cff;
|
|
||||||
text-decoration: inherit;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #535bf2;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 3.2em;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 0.6em 1.2em;
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.25s;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
border-color: #646cff;
|
|
||||||
}
|
|
||||||
button:focus,
|
|
||||||
button:focus-visible {
|
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
:root {
|
|
||||||
color: #213547;
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #747bff;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,17 @@
|
||||||
import React from 'react'
|
import { StrictMode } from "react"
|
||||||
import ReactDOM from 'react-dom/client'
|
|
||||||
import App from './App.tsx'
|
|
||||||
import './index.css'
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
import { createRoot } from "react-dom/client"
|
||||||
<React.StrictMode>
|
import { RouterProvider } from "react-router-dom"
|
||||||
<App />
|
|
||||||
</React.StrictMode>,
|
import "./index.css"
|
||||||
|
import router from "./router.tsx"
|
||||||
|
|
||||||
|
const rootEl = document.getElementById("root")
|
||||||
|
|
||||||
|
if (!rootEl) throw new Error("Root element not found")
|
||||||
|
|
||||||
|
createRoot(rootEl).render(
|
||||||
|
<StrictMode>
|
||||||
|
<RouterProvider router={router} fallbackElement={<p>Loading...</p>} />
|
||||||
|
</StrictMode>
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { FunctionComponent } from "react"
|
||||||
|
|
||||||
|
const IndexPage: FunctionComponent = () => {
|
||||||
|
return <div>Index</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IndexPage
|
|
@ -0,0 +1,41 @@
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
useActionData,
|
||||||
|
useLocation,
|
||||||
|
useNavigation,
|
||||||
|
} from "react-router-dom"
|
||||||
|
|
||||||
|
export function Component() {
|
||||||
|
const location = useLocation()
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
const from = params.get("from") || "/"
|
||||||
|
|
||||||
|
const navigation = useNavigation()
|
||||||
|
const isLoggingIn = navigation.formData?.get("username") != null
|
||||||
|
|
||||||
|
const actionData = useActionData() as { error: string } | undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>You must log in to view the page at {from}</p>
|
||||||
|
|
||||||
|
<Form method="post" replace>
|
||||||
|
<input type="hidden" name="redirectTo" value={from} />
|
||||||
|
<label>
|
||||||
|
Username: <input name="username" />
|
||||||
|
</label>{" "}
|
||||||
|
<label>
|
||||||
|
Password: <input type="password" name="password" />
|
||||||
|
</label>{" "}
|
||||||
|
<button type="submit" disabled={isLoggingIn}>
|
||||||
|
{isLoggingIn ? "Logging in..." : "Login"}
|
||||||
|
</button>
|
||||||
|
{actionData && actionData.error ? (
|
||||||
|
<p style={{ color: "red" }}>{actionData.error}</p>
|
||||||
|
) : null}
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.displayName = "LoginPage"
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { FunctionComponent } from "react"
|
||||||
|
|
||||||
|
const NotFoundPage: FunctionComponent = () => {
|
||||||
|
return <div>Not Found</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotFoundPage
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { LoaderFunction, json, useLoaderData } from "react-router-dom"
|
||||||
|
|
||||||
|
import { protectedLoader } from "../hooks/useAuth"
|
||||||
|
|
||||||
|
type Session = {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loader: LoaderFunction = async (args) => {
|
||||||
|
const redirect = await protectedLoader(args)
|
||||||
|
if (redirect) return redirect
|
||||||
|
|
||||||
|
const data: Session[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: "Session 1",
|
||||||
|
description: "Session 1 description",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: "Session 2",
|
||||||
|
description: "Session 2 description",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: "Session 3",
|
||||||
|
description: "Session 3 description",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return json(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Component() {
|
||||||
|
const data = useLoaderData() as Session[]
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>Sessions</div>
|
||||||
|
<ul>
|
||||||
|
{data.map((s) => (
|
||||||
|
<li key={s.id}>{s.title}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.displayName = "SessionsPage"
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { LoaderFunction, redirect, useLoaderData } from "react-router-dom"
|
||||||
|
|
||||||
|
import { protectedLoader } from "../hooks/useAuth"
|
||||||
|
import fetchAPI from "../util/fetchAPI"
|
||||||
|
|
||||||
|
type Short = {
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loader: LoaderFunction = async (args) => {
|
||||||
|
const resp = await protectedLoader(args)
|
||||||
|
if (resp) return resp
|
||||||
|
|
||||||
|
const data = await fetchAPI<Short[]>("/short")
|
||||||
|
if (data.ok) {
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
return redirect("/logout")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Component() {
|
||||||
|
const data = (useLoaderData() ?? []) as Short[]
|
||||||
|
console.log(data)
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>Shorts</div>
|
||||||
|
<ul>
|
||||||
|
{data.map((s) => (
|
||||||
|
<li key={s.name}>{s.name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.displayName = "ShortsPage"
|
|
@ -0,0 +1,41 @@
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
useActionData,
|
||||||
|
useLocation,
|
||||||
|
useNavigation,
|
||||||
|
} from "react-router-dom"
|
||||||
|
|
||||||
|
export function Component() {
|
||||||
|
const location = useLocation()
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
const from = params.get("from") || "/"
|
||||||
|
|
||||||
|
const navigation = useNavigation()
|
||||||
|
const isLoggingIn = navigation.formData?.get("username") != null
|
||||||
|
|
||||||
|
const actionData = useActionData() as { error: string } | undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>You must log in to view the page at {from}</p>
|
||||||
|
|
||||||
|
<Form method="post" replace>
|
||||||
|
<input type="hidden" name="redirectTo" value={from} />
|
||||||
|
<label>
|
||||||
|
Username: <input name="username" />
|
||||||
|
</label>{" "}
|
||||||
|
<label>
|
||||||
|
Password: <input type="password" name="password" />
|
||||||
|
</label>{" "}
|
||||||
|
<button type="submit" disabled={isLoggingIn}>
|
||||||
|
{isLoggingIn ? "Logging in..." : "Login"}
|
||||||
|
</button>
|
||||||
|
{actionData && actionData.error ? (
|
||||||
|
<p style={{ color: "red" }}>{actionData.error}</p>
|
||||||
|
) : null}
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.displayName = "LoginPage"
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { LoaderFunction, json, useLoaderData } from "react-router-dom"
|
||||||
|
|
||||||
|
import { protectedLoader } from "../hooks/useAuth"
|
||||||
|
|
||||||
|
type Token = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loader: LoaderFunction = async (args) => {
|
||||||
|
const redirect = await protectedLoader(args)
|
||||||
|
if (redirect) return redirect
|
||||||
|
|
||||||
|
const data: Token[] = [
|
||||||
|
{ id: 1, name: "Token 1" },
|
||||||
|
{ id: 2, name: "Token 2" },
|
||||||
|
{ id: 3, name: "Token 3" },
|
||||||
|
]
|
||||||
|
return json(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Component() {
|
||||||
|
const data = useLoaderData() as Token[]
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>Tokens</div>
|
||||||
|
<ul>
|
||||||
|
{data.map((s) => (
|
||||||
|
<li key={s.id}>{s.name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.displayName = "TokensPage"
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { createBrowserRouter } from "react-router-dom"
|
||||||
|
|
||||||
|
import App from "./App"
|
||||||
|
import {
|
||||||
|
indexLoader,
|
||||||
|
loginAction,
|
||||||
|
loginLoader,
|
||||||
|
logoutLoader,
|
||||||
|
signupLoader,
|
||||||
|
singupAction,
|
||||||
|
} from "./hooks/useAuth"
|
||||||
|
import IndexPage from "./pages/Index"
|
||||||
|
import NotFound from "./pages/NotFound"
|
||||||
|
|
||||||
|
export default createBrowserRouter([
|
||||||
|
{
|
||||||
|
id: "root",
|
||||||
|
path: "/",
|
||||||
|
element: <App />,
|
||||||
|
// Set the current user as the data for the root route, so we can access it
|
||||||
|
// via our `useUser` hook.
|
||||||
|
loader: indexLoader,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <IndexPage /> },
|
||||||
|
{
|
||||||
|
path: "login",
|
||||||
|
lazy: () => import("./pages/Login"),
|
||||||
|
loader: loginLoader,
|
||||||
|
action: loginAction,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "signup",
|
||||||
|
lazy: () => import("./pages/Signup"),
|
||||||
|
loader: signupLoader,
|
||||||
|
action: singupAction,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "logout",
|
||||||
|
loader: logoutLoader,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "shorts",
|
||||||
|
lazy: () => import("./pages/Shorts"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "tokens",
|
||||||
|
lazy: () => import("./pages/Tokens"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "sessions",
|
||||||
|
lazy: () => import("./pages/Sessions"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "*",
|
||||||
|
element: <NotFound />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
|
@ -0,0 +1,8 @@
|
||||||
|
export type User = {
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Short = {
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
// Fetch function that automatically points to the API URL
|
||||||
|
export default async function <T>(
|
||||||
|
path: string,
|
||||||
|
args: Parameters<typeof fetch>[1] = {}
|
||||||
|
): Promise<{ data: T; ok: true } | { data: null; ok: false }> {
|
||||||
|
args.credentials = "include"
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${import.meta.env.VITE_API_URL || ""}${path}`,
|
||||||
|
args
|
||||||
|
)
|
||||||
|
|
||||||
|
// if the response was not ok
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(response.statusText)
|
||||||
|
|
||||||
|
return { data: null, ok: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// on a successfull response, return the response
|
||||||
|
const data: T = await response.json()
|
||||||
|
return { data, ok: true }
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
|
@ -30,4 +30,4 @@
|
||||||
"path": "./tsconfig.node.json"
|
"path": "./tsconfig.node.json"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,5 +6,7 @@
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": [
|
||||||
|
"vite.config.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { defineConfig } from 'vite'
|
import react from "@vitejs/plugin-react-swc"
|
||||||
import react from '@vitejs/plugin-react-swc'
|
import { defineConfig } from "vite"
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|
Loading…
Reference in New Issue
Block a user