This commit is contained in:
Gustavo Maronato 2023-08-18 19:51:10 -03:00
parent 670fcc3303
commit c79562ecf2
Signed by: maronato
SSH Key Fingerprint: SHA256:2Gw7kwMz/As+2UkR1qQ/qYYhn+WNh3FGv6ozhoRrLcs
26 changed files with 3177 additions and 264 deletions

View File

@ -4,6 +4,7 @@ import (
"context"
"flag"
"fmt"
"net"
"net/http"
"git.maronato.dev/maronato/goshort/cmd/shared"
@ -92,7 +93,10 @@ func serveAPI(ctx context.Context, cfg *config.Config) error {
// Configure app routes
server.Mux.Route("/api", func(r chi.Router) {
// 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.Mount("/", apiRouter)
})

16
frontend/.eslintrc.json Normal file
View File

@ -0,0 +1,16 @@
{
"ignorePatterns": [
"**/*.js"
],
"env": {
"browser": true,
"es2021": true,
"node": true
},
"parserOptions": {
"ecmaVersion": "latest"
},
"extends": [
"@marolint/eslint-config-react"
]
}

View File

@ -0,0 +1 @@
"@marolint/eslint-config-react/prettier.config"

File diff suppressed because it is too large Load Diff

View File

@ -10,19 +10,22 @@
"preview": "vite preview"
},
"dependencies": {
"classnames": "^2.3.2",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"react-router-dom": "^6.15.0"
},
"devDependencies": {
"@marolint/eslint-config-react": "^1.0.2",
"@types/react": "^18.2.15",
"@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",
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"typescript": "^5.0.2",
"autoprefixer": "^10.4.15",
"eslint": "^8.47.0",
"postcss": "^8.4.28",
"prettier": "^2.8.8",
"tailwindcss": "^3.3.3",
"typescript": "^5.1.6",
"vite": "^4.4.5"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -2,41 +2,4 @@
max-width: 1280px;
margin: 0 auto;
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;
}

View File

@ -1,41 +1,38 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
import { FunctionComponent } from "react"
function App() {
const [count, setCount] = useState(0)
import { Outlet } from "react-router-dom"
const incrCount = () => {
setCount(count + 1)
fetch(import.meta.env.VITE_API_URL).then((res) => {
console.log(res)
})
}
import "./App.css"
import Button from "./components/Button"
import { useIsAuthenticated } from "./hooks/useAuth"
const App: FunctionComponent = () => {
const isAuthenticated = useIsAuthenticated()
return (
<>
<div>
<a href="https://vitejs.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
<div className="flex flex-col">
<div className="grid grid-cols-3 justify-between text-slate-500 font-medium">
<div className="flex-flex-row mr-auto text-lg">
<Button text="GoShort" link="/" />
</div>
<div className="flex flex-row mx-auto">
{isAuthenticated && (
<>
<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>
<h1>Vite + React</h1>
<div className="card">
<button onClick={incrCount}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
<div className="flex flex-col">
<Outlet />
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
</div>
)
}

View File

@ -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

View File

@ -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
}

View File

@ -1,69 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, system-ui, Avenir, 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;
}
font-family: -apple-system, SF Pro Text, SF UI Text, system-ui, Helvetica Neue, Helvetica, Arial, sans-serif;
}

View File

@ -1,10 +1,17 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import { StrictMode } from "react"
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
import { createRoot } from "react-dom/client"
import { RouterProvider } from "react-router-dom"
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>
)

View File

@ -0,0 +1,7 @@
import { FunctionComponent } from "react"
const IndexPage: FunctionComponent = () => {
return <div>Index</div>
}
export default IndexPage

View File

@ -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"

View File

@ -0,0 +1,7 @@
import { FunctionComponent } from "react"
const NotFoundPage: FunctionComponent = () => {
return <div>Not Found</div>
}
export default NotFoundPage

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

59
frontend/src/router.tsx Normal file
View File

@ -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 />,
},
],
},
])

8
frontend/src/types.ts Normal file
View File

@ -0,0 +1,8 @@
export type User = {
username: string
}
export type Short = {
name: string
url: string
}

View File

@ -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 }
}

View File

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@ -30,4 +30,4 @@
"path": "./tsconfig.node.json"
}
]
}
}

View File

@ -6,5 +6,7 @@
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
"include": [
"vite.config.ts"
]
}

View File

@ -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/
export default defineConfig({