diff options
32 files changed, 923 insertions, 772 deletions
diff --git a/docs/DEV.md b/docs/DEV.md index 6f1ab45..c8a4686 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -2,7 +2,7 @@ * Reverse proxy server (Nginx) * API server (Golang/Echo) -* App server (TypeScript/Remix) +* Frontend (TypeScript/React/Vite) * Database (PostgreSQL) * Worker (Golang/Echo + Swift/SwiftWasm + WebAssembly/Wasmtime) diff --git a/frontend/.gitignore b/frontend/.gitignore index f6b5418..338b475 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,6 +1,6 @@ /node_modules/ /.react-router/ -/build/ +/dist/ .env diff --git a/frontend/app/.server/auth.ts b/frontend/app/.server/auth.ts deleted file mode 100644 index 3e24638..0000000 --- a/frontend/app/.server/auth.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { type JwtPayload, jwtDecode } from "jwt-decode"; -import { redirect } from "react-router"; -import { Authenticator } from "remix-auth"; -import { FormStrategy } from "remix-auth-form"; -import { apiLogin } from "../api/client"; -import { components } from "../api/schema"; -import { createUnstructuredCookie } from "./cookie"; -import { cookieOptions, sessionStorage } from "./session"; - -const authenticator = new Authenticator<string>(); - -authenticator.use( - new FormStrategy(async ({ form }) => { - const username = String(form.get("username")); - const password = String(form.get("password")); - return (await apiLogin(username, password)).token; - }), - "default", -); - -export type User = components["schemas"]["User"]; - -// This cookie is used to directly store the JWT for the API server. -// Remix's createCookie() returns "structured" cookies, which cannot be reused directly by non-Remix servers. -const tokenCookie = createUnstructuredCookie("albatross_token", cookieOptions); - -/** - * @throws Error on failure - */ -export async function login(request: Request): Promise<never> { - const jwt = await authenticator.authenticate("default", request); - - const session = await sessionStorage.getSession( - request.headers.get("cookie"), - ); - session.set("user", jwt); - - throw redirect("/dashboard", { - headers: [ - ["Set-Cookie", await sessionStorage.commitSession(session)], - ["Set-Cookie", await tokenCookie.serialize(jwt)], - ], - }); -} - -export async function logout(request: Request): Promise<never> { - const session = await sessionStorage.getSession( - request.headers.get("cookie"), - ); - throw redirect("/", { - headers: [ - ["Set-Cookie", await sessionStorage.destroySession(session)], - [ - "Set-Cookie", - await tokenCookie.serialize("", { maxAge: 0, expires: new Date(0) }), - ], - ], - }); -} - -async function getCurrentValidSession( - request: Request, -): Promise<{ user: User; token: string } | null> { - const session = await sessionStorage.getSession( - request.headers.get("cookie"), - ); - const token = session.get("user"); - if (!token) { - return null; - } - const user = jwtDecode<User & JwtPayload>(token); - const exp = user.exp; - if (exp != null && new Date((exp - 3600) * 1000) < new Date()) { - // If the token will expire in less than an hour, refresh it. - return null; - } - return { user, token }; -} - -export async function ensureUserLoggedIn( - request: Request, -): Promise<{ user: User; token: string }> { - const session = await getCurrentValidSession(request); - if (!session) { - throw redirect("/login"); - } - return session; -} - -export async function ensureUserNotLoggedIn(request: Request): Promise<null> { - const session = await getCurrentValidSession(request); - if (session) { - throw redirect("/dashboard"); - } - return null; -} diff --git a/frontend/app/.server/cookie.ts b/frontend/app/.server/cookie.ts deleted file mode 100644 index c8365eb..0000000 --- a/frontend/app/.server/cookie.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { parse as parseCookie, serialize as serializeCookie } from "cookie"; -import { Cookie, CookieOptions } from "react-router"; - -// Remix's createCookie() returns "structured" cookies, which are cookies that hold a JSON-encoded object. -// This is not suitable for interoperation with other systems that expect a simple string value. -// This function creates an "unstructured" cookie, a simple plain text. -export function createUnstructuredCookie( - name: string, - cookieOptions?: CookieOptions, -): Cookie { - const { secrets = [], ...options } = { - path: "/", - sameSite: "lax" as const, - ...cookieOptions, - }; - - return { - get name() { - return name; - }, - get isSigned() { - return secrets.length > 0; - }, - get expires() { - return typeof options.maxAge !== "undefined" - ? new Date(Date.now() + options.maxAge * 1000) - : options.expires; - }, - async parse(cookieHeader, parseOptions) { - if (!cookieHeader) return null; - const cookies = parseCookie(cookieHeader, { - ...options, - ...parseOptions, - }); - return name in cookies ? cookies[name] : null; - }, - async serialize(value, serializeOptions) { - return serializeCookie(name, value, { - ...options, - ...serializeOptions, - }); - }, - }; -} diff --git a/frontend/app/.server/session.ts b/frontend/app/.server/session.ts deleted file mode 100644 index 0edcc35..0000000 --- a/frontend/app/.server/session.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { createCookieSessionStorage } from "react-router"; - -export const cookieOptions = { - sameSite: "lax" as const, - path: "/", - httpOnly: true, - secure: process.env.NODE_ENV === "production", - secrets: [process.env.ALBATROSS_COOKIE_SECRET ?? "local"], -}; - -const innerSessionStorage = createCookieSessionStorage({ - cookie: { - name: "albatross_session", - ...cookieOptions, - }, -}); -type InnerSessionStorage = typeof innerSessionStorage; - -// This class is used to recover from invalid sessions. -// It may occur if the session had been created before the authentication library was updated. -class RecoverableSessionStorage { - innerStorage: InnerSessionStorage; - - constructor(innerStorage: InnerSessionStorage) { - this.innerStorage = innerStorage; - } - - // If the session is invalid, return a new session. - // It may occur if the session had been created before the authentication library was updated. - getSession(...args: Parameters<InnerSessionStorage["getSession"]>) { - try { - return this.innerStorage.getSession(...args); - } catch (e) { - void e; - return this.innerStorage.getSession(); - } - } - - commitSession(...args: Parameters<InnerSessionStorage["commitSession"]>) { - return this.innerStorage.commitSession(...args); - } - - destroySession(...args: Parameters<InnerSessionStorage["destroySession"]>) { - return this.innerStorage.destroySession(...args); - } -} - -export const sessionStorage = new RecoverableSessionStorage( - innerSessionStorage, -); diff --git a/frontend/app/App.tsx b/frontend/app/App.tsx new file mode 100644 index 0000000..fcf6977 --- /dev/null +++ b/frontend/app/App.tsx @@ -0,0 +1,58 @@ +import { Route, Router, Switch } from "wouter"; +import ProtectedRoute from "./components/ProtectedRoute"; +import PublicOnlyRoute from "./components/PublicOnlyRoute"; +import { BASE_PATH } from "./config"; +import DashboardPage from "./pages/DashboardPage"; +import GolfPlayPage from "./pages/GolfPlayPage"; +import GolfWatchPage from "./pages/GolfWatchPage"; +import IndexPage from "./pages/IndexPage"; +import LoginPage from "./pages/LoginPage"; +import TournamentPage from "./pages/TournamentPage"; + +export default function App() { + return ( + <Router base={BASE_PATH.replace(/\/$/, "")}> + <Switch> + <Route path="/"> + <PublicOnlyRoute> + <IndexPage /> + </PublicOnlyRoute> + </Route> + <Route path="/login"> + <PublicOnlyRoute> + <LoginPage /> + </PublicOnlyRoute> + </Route> + <Route path="/dashboard"> + <ProtectedRoute> + <DashboardPage /> + </ProtectedRoute> + </Route> + <Route path="/golf/:gameId/play"> + {(params) => ( + <ProtectedRoute> + <GolfPlayPage gameId={params.gameId} /> + </ProtectedRoute> + )} + </Route> + <Route path="/golf/:gameId/watch"> + {(params) => ( + <ProtectedRoute> + <GolfWatchPage gameId={params.gameId} /> + </ProtectedRoute> + )} + </Route> + <Route path="/tournament"> + <ProtectedRoute> + <TournamentPage /> + </ProtectedRoute> + </Route> + <Route> + <div className="min-h-screen bg-gray-100 flex items-center justify-center"> + <p className="text-gray-500 text-xl">404 - Page not found</p> + </div> + </Route> + </Switch> + </Router> + ); +} diff --git a/frontend/app/auth.ts b/frontend/app/auth.ts new file mode 100644 index 0000000..7a3d10d --- /dev/null +++ b/frontend/app/auth.ts @@ -0,0 +1,45 @@ +import { type JwtPayload, jwtDecode } from "jwt-decode"; +import type { components } from "./api/schema"; + +export type User = components["schemas"]["User"]; + +const COOKIE_NAME = "albatross_token"; + +export function getToken(): string | null { + const match = document.cookie + .split("; ") + .find((row) => row.startsWith(`${COOKIE_NAME}=`)); + if (!match) return null; + return match.split("=").slice(1).join("="); +} + +export function setToken(token: string): void { + document.cookie = `${COOKIE_NAME}=${token}; path=/; SameSite=Lax`; +} + +export function clearToken(): void { + document.cookie = `${COOKIE_NAME}=; path=/; SameSite=Lax; max-age=0`; +} + +export function getUserFromToken(): User | null { + const token = getToken(); + if (!token) return null; + try { + return jwtDecode<User & JwtPayload>(token); + } catch { + return null; + } +} + +export function isTokenExpired(): boolean { + const token = getToken(); + if (!token) return true; + try { + const decoded = jwtDecode<JwtPayload>(token); + if (decoded.exp == null) return false; + // If the token will expire in less than an hour, treat it as expired. + return new Date((decoded.exp - 3600) * 1000) < new Date(); + } catch { + return true; + } +} diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx index 9eab91e..025e676 100644 --- a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx @@ -1,6 +1,6 @@ import { useAtomValue } from "jotai"; import React, { useRef, useState } from "react"; -import { Link } from "react-router"; +import { Link } from "wouter"; import { calcCodeSize, gamingLeftTimeSecondsAtom, diff --git a/frontend/app/components/NavigateLink.tsx b/frontend/app/components/NavigateLink.tsx index c4ee7aa..16c7858 100644 --- a/frontend/app/components/NavigateLink.tsx +++ b/frontend/app/components/NavigateLink.tsx @@ -1,10 +1,18 @@ -import { Link, LinkProps } from "react-router"; +import { Link } from "wouter"; -export default function NavigateLink(props: LinkProps) { +export default function NavigateLink({ + to, + children, +}: { + to: string; + children: React.ReactNode; +}) { return ( <Link - {...props} + to={to} className="text-lg text-white bg-sky-600 px-4 py-2 border-2 border-sky-50 rounded-sm transition duration-300 hover:bg-sky-500 focus:ring-3 focus:ring-sky-400 focus:outline-hidden" - /> + > + {children} + </Link> ); } diff --git a/frontend/app/components/ProtectedRoute.tsx b/frontend/app/components/ProtectedRoute.tsx new file mode 100644 index 0000000..3aeaebc --- /dev/null +++ b/frontend/app/components/ProtectedRoute.tsx @@ -0,0 +1,16 @@ +import { Redirect } from "wouter"; +import { useAuth } from "../hooks/useAuth"; + +export default function ProtectedRoute({ + children, +}: { + children: React.ReactNode; +}) { + const { isLoggedIn } = useAuth(); + + if (!isLoggedIn) { + return <Redirect to="/login" />; + } + + return <>{children}</>; +} diff --git a/frontend/app/components/PublicOnlyRoute.tsx b/frontend/app/components/PublicOnlyRoute.tsx new file mode 100644 index 0000000..2527918 --- /dev/null +++ b/frontend/app/components/PublicOnlyRoute.tsx @@ -0,0 +1,16 @@ +import { Redirect } from "wouter"; +import { useAuth } from "../hooks/useAuth"; + +export default function PublicOnlyRoute({ + children, +}: { + children: React.ReactNode; +}) { + const { isLoggedIn } = useAuth(); + + if (isLoggedIn) { + return <Redirect to="/dashboard" />; + } + + return <>{children}</>; +} diff --git a/frontend/app/hooks/useAuth.ts b/frontend/app/hooks/useAuth.ts new file mode 100644 index 0000000..8762734 --- /dev/null +++ b/frontend/app/hooks/useAuth.ts @@ -0,0 +1,58 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { apiLogin } from "../api/client"; +import { + type User, + clearToken, + getToken, + getUserFromToken, + isTokenExpired, + setToken, +} from "../auth"; + +// Simple external store to trigger re-renders when auth state changes. +let authVersion = 0; +const listeners = new Set<() => void>(); + +function subscribe(callback: () => void) { + listeners.add(callback); + return () => listeners.delete(callback); +} + +function getSnapshot() { + return authVersion; +} + +function notifyAuthChange() { + authVersion++; + for (const listener of listeners) { + listener(); + } +} + +export function useAuth(): { + user: User | null; + token: string | null; + isLoggedIn: boolean; + login: (username: string, password: string) => Promise<void>; + logout: () => void; +} { + useSyncExternalStore(subscribe, getSnapshot); + + const token = getToken(); + const isExpired = isTokenExpired(); + const user = isExpired ? null : getUserFromToken(); + const isLoggedIn = user !== null && !isExpired; + + const login = useCallback(async (username: string, password: string) => { + const { token } = await apiLogin(username, password); + setToken(token); + notifyAuthChange(); + }, []); + + const logout = useCallback(() => { + clearToken(); + notifyAuthChange(); + }, []); + + return { user, token: isLoggedIn ? token : null, isLoggedIn, login, logout }; +} diff --git a/frontend/app/hooks/usePageTitle.ts b/frontend/app/hooks/usePageTitle.ts new file mode 100644 index 0000000..fb8def5 --- /dev/null +++ b/frontend/app/hooks/usePageTitle.ts @@ -0,0 +1,7 @@ +import { useEffect } from "react"; + +export function usePageTitle(title: string) { + useEffect(() => { + document.title = title; + }, [title]); +} diff --git a/frontend/app/main.tsx b/frontend/app/main.tsx new file mode 100644 index 0000000..89ed944 --- /dev/null +++ b/frontend/app/main.tsx @@ -0,0 +1,20 @@ +import { config } from "@fortawesome/fontawesome-svg-core"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "@fortawesome/fontawesome-svg-core/styles.css"; +import "./tailwind.css"; +import "./shiki.css"; +import App from "./App"; + +config.autoAddCss = false; + +const root = document.getElementById("root"); +if (!root) { + throw new Error("Root element not found"); +} + +createRoot(root).render( + <StrictMode> + <App /> + </StrictMode>, +); diff --git a/frontend/app/routes/dashboard.tsx b/frontend/app/pages/DashboardPage.tsx index f44fe79..c81014d 100644 --- a/frontend/app/routes/dashboard.tsx +++ b/frontend/app/pages/DashboardPage.tsx @@ -1,38 +1,59 @@ -import type { LoaderFunctionArgs, MetaFunction } from "react-router"; -import { Form, useLoaderData } from "react-router"; -import { ensureUserLoggedIn } from "../.server/auth"; +import { useEffect, useState } from "react"; +import { useLocation } from "wouter"; import { createApiClient } from "../api/client"; +import type { components } from "../api/schema"; +import { getToken } from "../auth"; import BorderedContainerWithCaption from "../components/BorderedContainerWithCaption"; import NavigateLink from "../components/NavigateLink"; import UserIcon from "../components/UserIcon"; import { APP_NAME, BASE_PATH } from "../config"; +import { useAuth } from "../hooks/useAuth"; +import { usePageTitle } from "../hooks/usePageTitle"; -export const meta: MetaFunction = () => [{ title: `Dashboard | ${APP_NAME}` }]; +type Game = components["schemas"]["Game"]; -export async function loader({ request }: LoaderFunctionArgs) { - const { user, token } = await ensureUserLoggedIn(request); - const apiClient = createApiClient(token); +export default function DashboardPage() { + usePageTitle(`Dashboard | ${APP_NAME}`); - const { games } = await apiClient.getGames(); - return { - user, - games, - }; -} + const { user, logout } = useAuth(); + const [, navigate] = useLocation(); + + const [games, setGames] = useState<Game[]>([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = getToken(); + if (!token) return; + const apiClient = createApiClient(token); + apiClient + .getGames() + .then(({ games }) => setGames(games)) + .finally(() => setLoading(false)); + }, []); -export default function Dashboard() { - const { user, games } = useLoaderData<typeof loader>()!; + function handleLogout() { + logout(); + navigate("/"); + } + + if (loading) { + return ( + <div className="min-h-screen bg-gray-100 flex items-center justify-center"> + <p className="text-gray-500">Loading...</p> + </div> + ); + } return ( <div className="p-6 bg-gray-100 min-h-screen flex flex-col items-center gap-4"> - {user.icon_path && ( + {user?.icon_path && ( <UserIcon iconPath={user.icon_path} displayName={user.display_name} className="w-24 h-24" /> )} - <h1 className="text-3xl font-bold text-gray-800">{user.display_name}</h1> + <h1 className="text-3xl font-bold text-gray-800">{user?.display_name}</h1> <BorderedContainerWithCaption caption="試合一覧"> <div className="px-4"> {games.length === 0 ? ( @@ -63,18 +84,17 @@ export default function Dashboard() { )} </div> </BorderedContainerWithCaption> - <Form method="post" action="/logout"> - <button - type="submit" - className="px-4 py-2 bg-red-500 text-white rounded-sm transition duration-300 hover:bg-red-700 focus:ring-3 focus:ring-red-400 focus:outline-hidden" - > - ログアウト - </button> - </Form> - {user.is_admin && ( + <button + type="button" + onClick={handleLogout} + className="px-4 py-2 bg-red-500 text-white rounded-sm transition duration-300 hover:bg-red-700 focus:ring-3 focus:ring-red-400 focus:outline-hidden" + > + ログアウト + </button> + {user?.is_admin && ( <a href={ - process.env.NODE_ENV === "development" + import.meta.env.DEV ? `http://localhost:8004${BASE_PATH}admin/dashboard` : `${BASE_PATH}admin/dashboard` } diff --git a/frontend/app/pages/GolfPlayPage.tsx b/frontend/app/pages/GolfPlayPage.tsx new file mode 100644 index 0000000..3fddbf8 --- /dev/null +++ b/frontend/app/pages/GolfPlayPage.tsx @@ -0,0 +1,74 @@ +import { Provider as JotaiProvider, createStore } from "jotai"; +import { useEffect, useMemo, useState } from "react"; +import { useLocation } from "wouter"; +import { ApiClientContext, createApiClient } from "../api/client"; +import type { components } from "../api/schema"; +import { getToken } from "../auth"; +import GolfPlayApp from "../components/GolfPlayApp"; +import { APP_NAME } from "../config"; +import { useAuth } from "../hooks/useAuth"; +import { usePageTitle } from "../hooks/usePageTitle"; + +type Game = components["schemas"]["Game"]; +type LatestGameState = components["schemas"]["LatestGameState"]; + +export default function GolfPlayPage({ gameId }: { gameId: string }) { + const { user } = useAuth(); + const [, navigate] = useLocation(); + + const [game, setGame] = useState<Game | null>(null); + const [gameState, setGameState] = useState<LatestGameState | null>(null); + const [loading, setLoading] = useState(true); + + const gameIdNum = Number(gameId); + + usePageTitle( + game + ? `Golf Playing ${game.display_name} | ${APP_NAME}` + : `Golf Playing | ${APP_NAME}`, + ); + + useEffect(() => { + const token = getToken(); + if (!token) return; + const apiClient = createApiClient(token); + Promise.all([ + apiClient.getGame(gameIdNum), + apiClient.getGamePlayLatestState(gameIdNum), + ]) + .then(([{ game }, { state }]) => { + setGame(game); + setGameState(state); + }) + .catch(() => navigate("/dashboard")) + .finally(() => setLoading(false)); + }, [gameIdNum, navigate]); + + const store = useMemo(() => { + if (!game || !user) return null; + return createStore(); + }, [game, user]); + + if (loading || !game || !gameState || !user || !store) { + return ( + <div className="min-h-screen bg-gray-100 flex items-center justify-center"> + <p className="text-gray-500">Loading...</p> + </div> + ); + } + + const token = getToken()!; + + return ( + <JotaiProvider store={store}> + <ApiClientContext.Provider value={createApiClient(token)}> + <GolfPlayApp + key={game.game_id} + game={game} + player={user} + initialGameState={gameState} + /> + </ApiClientContext.Provider> + </JotaiProvider> + ); +} diff --git a/frontend/app/pages/GolfWatchPage.tsx b/frontend/app/pages/GolfWatchPage.tsx new file mode 100644 index 0000000..317f860 --- /dev/null +++ b/frontend/app/pages/GolfWatchPage.tsx @@ -0,0 +1,78 @@ +import { Provider as JotaiProvider, createStore } from "jotai"; +import { useEffect, useMemo, useState } from "react"; +import { useLocation } from "wouter"; +import { ApiClientContext, createApiClient } from "../api/client"; +import type { components } from "../api/schema"; +import { getToken } from "../auth"; +import GolfWatchApp from "../components/GolfWatchApp"; +import { APP_NAME } from "../config"; +import { usePageTitle } from "../hooks/usePageTitle"; + +type Game = components["schemas"]["Game"]; +type LatestGameState = components["schemas"]["LatestGameState"]; +type RankingEntry = components["schemas"]["RankingEntry"]; + +export default function GolfWatchPage({ gameId }: { gameId: string }) { + const [, navigate] = useLocation(); + + const [game, setGame] = useState<Game | null>(null); + const [ranking, setRanking] = useState<RankingEntry[]>([]); + const [gameStates, setGameStates] = useState<{ + [key: string]: LatestGameState; + }>({}); + const [loading, setLoading] = useState(true); + + const gameIdNum = Number(gameId); + + usePageTitle( + game + ? `Golf Watching ${game.display_name} | ${APP_NAME}` + : `Golf Watching | ${APP_NAME}`, + ); + + useEffect(() => { + const token = getToken(); + if (!token) return; + const apiClient = createApiClient(token); + Promise.all([ + apiClient.getGame(gameIdNum), + apiClient.getGameWatchRanking(gameIdNum), + apiClient.getGameWatchLatestStates(gameIdNum), + ]) + .then(([{ game }, { ranking }, { states }]) => { + setGame(game); + setRanking(ranking); + setGameStates(states); + }) + .catch(() => navigate("/dashboard")) + .finally(() => setLoading(false)); + }, [gameIdNum, navigate]); + + const store = useMemo(() => { + if (!game) return null; + return createStore(); + }, [game]); + + if (loading || !game || !store) { + return ( + <div className="min-h-screen bg-gray-100 flex items-center justify-center"> + <p className="text-gray-500">Loading...</p> + </div> + ); + } + + const token = getToken()!; + + return ( + <JotaiProvider store={store}> + <ApiClientContext.Provider value={createApiClient(token)}> + <GolfWatchApp + key={game.game_id} + game={game} + initialGameStates={gameStates} + initialRanking={ranking} + /> + </ApiClientContext.Provider> + </JotaiProvider> + ); +} diff --git a/frontend/app/routes/_index.tsx b/frontend/app/pages/IndexPage.tsx index 207b175..088cdc5 100644 --- a/frontend/app/routes/_index.tsx +++ b/frontend/app/pages/IndexPage.tsx @@ -1,17 +1,11 @@ -import type { LoaderFunctionArgs, MetaFunction } from "react-router"; -import { ensureUserNotLoggedIn } from "../.server/auth"; import BorderedContainer from "../components/BorderedContainer"; import NavigateLink from "../components/NavigateLink"; import { APP_NAME, BASE_PATH } from "../config"; +import { usePageTitle } from "../hooks/usePageTitle"; -export const meta: MetaFunction = () => [{ title: APP_NAME }]; +export default function IndexPage() { + usePageTitle(APP_NAME); -export async function loader({ request }: LoaderFunctionArgs) { - await ensureUserNotLoggedIn(request); - return null; -} - -export default function Index() { return ( <div className="min-h-screen bg-gray-100 flex flex-col items-center justify-center gap-y-6"> <img diff --git a/frontend/app/pages/LoginPage.tsx b/frontend/app/pages/LoginPage.tsx new file mode 100644 index 0000000..c10b819 --- /dev/null +++ b/frontend/app/pages/LoginPage.tsx @@ -0,0 +1,101 @@ +import { type FormEvent, useState } from "react"; +import { useLocation } from "wouter"; +import BorderedContainer from "../components/BorderedContainer"; +import InputText from "../components/InputText"; +import SubmitButton from "../components/SubmitButton"; +import { APP_NAME } from "../config"; +import { useAuth } from "../hooks/useAuth"; +import { usePageTitle } from "../hooks/usePageTitle"; + +export default function LoginPage() { + usePageTitle(`Login | ${APP_NAME}`); + + const { login } = useAuth(); + const [, navigate] = useLocation(); + + const [error, setError] = useState<string | null>(null); + const [fieldErrors, setFieldErrors] = useState<{ + username?: string; + password?: string; + }>({}); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(e: FormEvent<HTMLFormElement>) { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const username = String(formData.get("username")); + const password = String(formData.get("password")); + + const errors: { username?: string; password?: string } = {}; + if (username === "") errors.username = "ユーザー名を入力してください"; + if (password === "") errors.password = "パスワードを入力してください"; + if (Object.keys(errors).length > 0) { + setFieldErrors(errors); + setError("ユーザー名またはパスワードが誤っています"); + return; + } + + setSubmitting(true); + setError(null); + setFieldErrors({}); + + try { + await login(username, password); + navigate("/dashboard"); + } catch (err) { + setError(err instanceof Error ? err.message : "ログインに失敗しました"); + } finally { + setSubmitting(false); + } + } + + return ( + <div className="min-h-screen bg-gray-100 flex items-center justify-center"> + <div className="mx-2"> + <BorderedContainer> + <form onSubmit={handleSubmit} className="w-full max-w-sm p-2"> + <h2 className="text-2xl mb-6 text-center"> + fortee アカウントでログイン + </h2> + {error && <p className="text-sky-500 text-sm mb-4">{error}</p>} + <div className="mb-4 flex flex-col gap-1"> + <label + htmlFor="username" + className="block text-sm font-medium text-gray-700" + > + ユーザー名 + </label> + <InputText type="text" name="username" id="username" required /> + {fieldErrors.username && ( + <p className="text-red-500 text-sm">{fieldErrors.username}</p> + )} + </div> + <div className="mb-6 flex flex-col gap-1"> + <label + htmlFor="password" + className="block text-sm font-medium text-gray-700" + > + パスワード + </label> + <InputText + type="password" + name="password" + id="password" + autoComplete="current-password" + required + /> + {fieldErrors.password && ( + <p className="text-red-500 text-sm">{fieldErrors.password}</p> + )} + </div> + <div className="flex justify-center"> + <SubmitButton type="submit" disabled={submitting}> + {submitting ? "ログイン中..." : "ログイン"} + </SubmitButton> + </div> + </form> + </BorderedContainer> + </div> + </div> + ); +} diff --git a/frontend/app/routes/tournament.tsx b/frontend/app/pages/TournamentPage.tsx index 162bd1a..43ea790 100644 --- a/frontend/app/routes/tournament.tsx +++ b/frontend/app/pages/TournamentPage.tsx @@ -1,86 +1,11 @@ -import type { LoaderFunctionArgs, MetaFunction } from "react-router"; -import { useLoaderData } from "react-router"; -import { ensureUserLoggedIn } from "../.server/auth"; +import { useEffect, useState } from "react"; import { createApiClient } from "../api/client"; import type { components } from "../api/schema"; +import { getToken } from "../auth"; import BorderedContainer from "../components/BorderedContainer"; import UserIcon from "../components/UserIcon"; import { APP_NAME } from "../config"; - -export const meta: MetaFunction = () => [{ title: `Tournament | ${APP_NAME}` }]; - -export async function loader({ request }: LoaderFunctionArgs) { - const { token } = await ensureUserLoggedIn(request); - const apiClient = createApiClient(token); - - const url = new URL(request.url); - const game1Param = url.searchParams.get("game1"); - const game2Param = url.searchParams.get("game2"); - const game3Param = url.searchParams.get("game3"); - const game4Param = url.searchParams.get("game4"); - const game5Param = url.searchParams.get("game5"); - const player1Param = url.searchParams.get("player1"); - const player2Param = url.searchParams.get("player2"); - const player3Param = url.searchParams.get("player3"); - const player4Param = url.searchParams.get("player4"); - const player5Param = url.searchParams.get("player5"); - const player6Param = url.searchParams.get("player6"); - - if (!game1Param || !game2Param || !game3Param || !game4Param || !game5Param) { - throw new Response( - "Missing required query parameters: game1, game2, game3, game4, game5", - { - status: 400, - }, - ); - } - if ( - !player1Param || - !player2Param || - !player3Param || - !player4Param || - !player5Param || - !player6Param - ) { - throw new Response( - "Missing required query parameters: player1, player2, player3, player4, player5, player6", - { - status: 400, - }, - ); - } - - const game1 = Number(game1Param); - const game2 = Number(game2Param); - const game3 = Number(game3Param); - const game4 = Number(game4Param); - const game5 = Number(game5Param); - - if (!game1 || !game2 || !game3 || !game4 || !game5) { - throw new Response("Invalid game IDs: must be positive integers", { - status: 400, - }); - } - - const { tournament } = await apiClient.getTournament( - game1, - game2, - game3, - game4, - game5, - ); - return { - tournament, - playerIDs: [ - Number(player1Param), - Number(player2Param), - Number(player3Param), - Number(player4Param), - Number(player5Param), - Number(player6Param), - ], - }; -} +import { usePageTitle } from "../hooks/usePageTitle"; type TournamentMatch = components["schemas"]["TournamentMatch"]; type User = components["schemas"]["User"]; @@ -253,8 +178,8 @@ function BranchR2({ className = "" }: { className?: string }) { function getPlayer(match: TournamentMatch, playerID: number): User | null { if (match.player1?.user_id === playerID) return match.player1; - else if (match.player2?.user_id === playerID) return match.player2; - else return null; + if (match.player2?.user_id === playerID) return match.player2; + return null; } function getScore(match: TournamentMatch, playerIDs: number[]): number | null { @@ -262,21 +187,85 @@ function getScore(match: TournamentMatch, playerIDs: number[]): number | null { return match.player1_score ?? null; if (match.player2 && playerIDs.includes(match.player2.user_id)) return match.player2_score ?? null; - else return null; + return null; } function getBorderColor(match: TournamentMatch, playerIDs: number[]): string { if (!match.winner) { return "border-black"; - } else if (playerIDs.includes(match.winner)) { + } + if (playerIDs.includes(match.winner)) { return "border-pink-700"; - } else { - return "border-gray-400"; } + return "border-gray-400"; } -export default function Tournament() { - const { tournament, playerIDs } = useLoaderData<typeof loader>(); +export default function TournamentPage() { + usePageTitle(`Tournament | ${APP_NAME}`); + + const [tournament, setTournament] = useState<{ + matches: TournamentMatch[]; + } | null>(null); + const [playerIDs, setPlayerIDs] = useState<number[]>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const game1 = Number(params.get("game1")); + const game2 = Number(params.get("game2")); + const game3 = Number(params.get("game3")); + const game4 = Number(params.get("game4")); + const game5 = Number(params.get("game5")); + + if (!game1 || !game2 || !game3 || !game4 || !game5) { + setError("Missing or invalid game parameters"); + setLoading(false); + return; + } + + const pIDs = [ + Number(params.get("player1")), + Number(params.get("player2")), + Number(params.get("player3")), + Number(params.get("player4")), + Number(params.get("player5")), + Number(params.get("player6")), + ]; + + if (pIDs.some((id) => !id)) { + setError("Missing or invalid player parameters"); + setLoading(false); + return; + } + + setPlayerIDs(pIDs); + + const token = getToken(); + if (!token) return; + const apiClient = createApiClient(token); + apiClient + .getTournament(game1, game2, game3, game4, game5) + .then(({ tournament }) => setTournament(tournament)) + .catch(() => setError("Failed to load tournament")) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( + <div className="min-h-screen bg-gray-100 flex items-center justify-center"> + <p className="text-gray-500">Loading...</p> + </div> + ); + } + + if (error || !tournament) { + return ( + <div className="min-h-screen bg-gray-100 flex items-center justify-center"> + <p className="text-red-500">{error || "Failed to load tournament"}</p> + </div> + ); + } const match1 = tournament.matches[0]!; const match2 = tournament.matches[1]!; diff --git a/frontend/app/root.tsx b/frontend/app/root.tsx deleted file mode 100644 index 5ecab0a..0000000 --- a/frontend/app/root.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { config } from "@fortawesome/fontawesome-svg-core"; -import "@fortawesome/fontawesome-svg-core/styles.css"; -import type { LinksFunction } from "react-router"; -import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"; -import "./tailwind.css"; -import "./shiki.css"; -import { BASE_PATH } from "./config"; - -config.autoAddCss = false; - -export const links: LinksFunction = () => [ - { rel: "icon", href: `${BASE_PATH}favicon.svg` }, -]; - -export function Layout({ children }: { children: React.ReactNode }) { - return ( - <html lang="ja"> - <head> - <meta charSet="utf-8" /> - <meta name="viewport" content="width=device-width, initial-scale=1" /> - <Meta /> - <Links /> - </head> - <body className="h-screen"> - {children} - <ScrollRestoration /> - <Scripts /> - <script>console.log(`#Albatross!`)</script> - </body> - </html> - ); -} - -export default function App() { - return <Outlet />; -} diff --git a/frontend/app/routes.ts b/frontend/app/routes.ts deleted file mode 100644 index 4c05936..0000000 --- a/frontend/app/routes.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { type RouteConfig } from "@react-router/dev/routes"; -import { flatRoutes } from "@react-router/fs-routes"; - -export default flatRoutes() satisfies RouteConfig; diff --git a/frontend/app/routes/golf.$gameId.play.tsx b/frontend/app/routes/golf.$gameId.play.tsx deleted file mode 100644 index c063d05..0000000 --- a/frontend/app/routes/golf.$gameId.play.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Provider as JotaiProvider, createStore } from "jotai"; -import { useMemo } from "react"; -import type { LoaderFunctionArgs, MetaFunction } from "react-router"; -import { redirect, useLoaderData } from "react-router"; -import { ensureUserLoggedIn } from "../.server/auth"; -import { ApiClientContext, createApiClient } from "../api/client"; -import GolfPlayApp from "../components/GolfPlayApp"; -import { APP_NAME } from "../config"; - -export const meta: MetaFunction<typeof loader> = ({ data }) => [ - { - title: data - ? `Golf Playing ${data.game.display_name} | ${APP_NAME}` - : `Golf Playing | ${APP_NAME}`, - }, -]; - -export async function loader({ params, request }: LoaderFunctionArgs) { - const { token, user } = await ensureUserLoggedIn(request); - const apiClient = createApiClient(token); - - const gameId = Number(params.gameId); - - try { - const [{ game }, { state: gameState }] = await Promise.all([ - apiClient.getGame(gameId), - apiClient.getGamePlayLatestState(gameId), - ]); - - return { - apiToken: token, - game, - player: user, - gameState, - }; - } catch { - throw redirect("/dashboard"); - } -} - -export default function GolfPlay() { - const { apiToken, game, player, gameState } = useLoaderData<typeof loader>(); - - const store = useMemo(() => { - void game.game_id; - void player.user_id; - return createStore(); - }, [game.game_id, player.user_id]); - - return ( - <JotaiProvider store={store}> - <ApiClientContext.Provider value={createApiClient(apiToken)}> - <GolfPlayApp - key={game.game_id} - game={game} - player={player} - initialGameState={gameState} - /> - </ApiClientContext.Provider> - </JotaiProvider> - ); -} diff --git a/frontend/app/routes/golf.$gameId.watch.tsx b/frontend/app/routes/golf.$gameId.watch.tsx deleted file mode 100644 index 7468be5..0000000 --- a/frontend/app/routes/golf.$gameId.watch.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { Provider as JotaiProvider, createStore } from "jotai"; -import { useMemo } from "react"; -import type { LoaderFunctionArgs, MetaFunction } from "react-router"; -import { redirect, useLoaderData } from "react-router"; -import { ensureUserLoggedIn } from "../.server/auth"; -import { ApiClientContext, createApiClient } from "../api/client"; -import GolfWatchApp from "../components/GolfWatchApp"; -import { APP_NAME } from "../config"; - -export const meta: MetaFunction<typeof loader> = ({ data }) => [ - { - title: data - ? `Golf Watching ${data.game.display_name} | ${APP_NAME}` - : `Golf Watching | ${APP_NAME}`, - }, -]; - -export async function loader({ params, request }: LoaderFunctionArgs) { - const { token } = await ensureUserLoggedIn(request); - const apiClient = createApiClient(token); - - const gameId = Number(params.gameId); - - try { - const [{ game }, { ranking }, { states: gameStates }] = await Promise.all([ - await apiClient.getGame(gameId), - await apiClient.getGameWatchRanking(gameId), - await apiClient.getGameWatchLatestStates(gameId), - ]); - - return { - apiToken: token, - game, - ranking, - gameStates, - }; - } catch { - throw redirect("/dashboard"); - } -} - -export default function GolfWatch() { - const { apiToken, game, ranking, gameStates } = - useLoaderData<typeof loader>(); - - const store = useMemo(() => { - void game.game_id; - return createStore(); - }, [game.game_id]); - - return ( - <JotaiProvider store={store}> - <ApiClientContext.Provider value={createApiClient(apiToken)}> - <GolfWatchApp - key={game.game_id} - game={game} - initialGameStates={gameStates} - initialRanking={ranking} - /> - </ApiClientContext.Provider> - </JotaiProvider> - ); -} diff --git a/frontend/app/routes/login.tsx b/frontend/app/routes/login.tsx deleted file mode 100644 index e394a6c..0000000 --- a/frontend/app/routes/login.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import type { - ActionFunctionArgs, - LoaderFunctionArgs, - MetaFunction, -} from "react-router"; -import { Form, data, useActionData } from "react-router"; -import { ensureUserNotLoggedIn, login } from "../.server/auth"; -import BorderedContainer from "../components/BorderedContainer"; -import InputText from "../components/InputText"; -import SubmitButton from "../components/SubmitButton"; -import { APP_NAME } from "../config"; - -export const meta: MetaFunction = () => [{ title: `Login | ${APP_NAME}` }]; - -export async function loader({ request }: LoaderFunctionArgs) { - return await ensureUserNotLoggedIn(request); -} - -export async function action({ request }: ActionFunctionArgs) { - const formData = await request.clone().formData(); - const username = String(formData.get("username")); - const password = String(formData.get("password")); - if (username === "" || password === "") { - return data( - { - message: "ユーザー名またはパスワードが誤っています", - errors: { - username: - username === "" ? "ユーザー名を入力してください" : undefined, - password: - password === "" ? "パスワードを入力してください" : undefined, - }, - }, - { status: 400 }, - ); - } - - try { - await login(request); - } catch (error) { - if (error instanceof Error) { - return data( - { - message: error.message, - errors: { - username: undefined, - password: undefined, - }, - }, - { status: 400 }, - ); - } else { - throw error; - } - } - return null; -} - -export default function Login() { - const loginErrors = useActionData<typeof action>(); - - return ( - <div className="min-h-screen bg-gray-100 flex items-center justify-center"> - <div className="mx-2"> - <BorderedContainer> - <Form method="post" className="w-full max-w-sm p-2"> - <h2 className="text-2xl mb-6 text-center"> - fortee アカウントでログイン - </h2> - {loginErrors?.message && ( - <p className="text-sky-500 text-sm mb-4">{loginErrors.message}</p> - )} - <div className="mb-4 flex flex-col gap-1"> - <label - htmlFor="username" - className="block text-sm font-medium text-gray-700" - > - ユーザー名 - </label> - <InputText type="text" name="username" id="username" required /> - {loginErrors?.errors?.username && ( - <p className="text-red-500 text-sm"> - {loginErrors.errors.username} - </p> - )} - </div> - <div className="mb-6 flex flex-col gap-1"> - <label - htmlFor="password" - className="block text-sm font-medium text-gray-700" - > - パスワード - </label> - <InputText - type="password" - name="password" - id="password" - autoComplete="current-password" - required - /> - {loginErrors?.errors?.password && ( - <p className="text-red-500 text-sm"> - {loginErrors.errors.password} - </p> - )} - </div> - <div className="flex justify-center"> - <SubmitButton type="submit">ログイン</SubmitButton> - </div> - </Form> - </BorderedContainer> - </div> - </div> - ); -} diff --git a/frontend/app/routes/logout.tsx b/frontend/app/routes/logout.tsx deleted file mode 100644 index 9616b4d..0000000 --- a/frontend/app/routes/logout.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import type { ActionFunctionArgs } from "react-router"; -import { logout } from "../.server/auth"; - -export async function action({ request }: ActionFunctionArgs) { - await logout(request); - return null; -} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9e12f15 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ +<!doctype html> +<html lang="ja"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <link rel="icon" href="/favicon.svg" /> + </head> + <body class="h-screen"> + <div id="root"></div> + <script type="module" src="/app/main.tsx"></script> + <script>console.log(`#Albatross!`)</script> + </body> +</html> diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 033d678..d6f0fc4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,7 +26,8 @@ "remix-auth": "^4.1.0", "remix-auth-form": "^3.0.0", "shiki": "^3.12.2", - "use-debounce": "^10.0.4" + "use-debounce": "^10.0.4", + "wouter": "^3.9.0" }, "devDependencies": { "@biomejs/biome": "^1.9.4", @@ -37,6 +38,7 @@ "@types/node": "^22.13.10", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.1.4", "eslint": "^9.22.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.4", @@ -68,58 +70,45 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", - "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz", - "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.9", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.9", - "@babel/parser": "^7.26.9", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.9", - "@babel/types": "^7.26.9", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -144,15 +133,15 @@ } }, "node_modules/@babel/generator": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz", - "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { @@ -172,13 +161,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz", - "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.26.5", - "@babel/helper-validator-option": "^7.25.9", + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -226,6 +215,15 @@ "semver": "bin/semver.js" } }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-member-expression-to-functions": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", @@ -240,27 +238,27 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", - "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -282,9 +280,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", - "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -321,52 +319,52 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz", - "integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "license": "MIT", "dependencies": { - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", - "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "license": "MIT", "dependencies": { - "@babel/types": "^7.26.9" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -436,6 +434,38 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-typescript": { "version": "7.26.8", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.26.8.tgz", @@ -487,54 +517,45 @@ } }, "node_modules/@babel/template": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", - "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz", - "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.9", - "@babel/parser": "^7.26.9", - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.9", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/types": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", - "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -1807,17 +1828,23 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -1829,25 +1856,16 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2973,6 +2991,13 @@ "node": ">=10" } }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.35.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.35.0.tgz", @@ -3549,6 +3574,51 @@ "vite": "^5.2.0 || ^6" } }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -3856,6 +3926,37 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/abbrev": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", @@ -4237,6 +4338,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -4345,9 +4455,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "funding": [ { "type": "opencollective", @@ -4364,10 +4474,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -4459,9 +4570,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001702", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001702.tgz", - "integrity": "sha512-LoPe/D7zioC0REI5W73PeR1e1MLCipRGq/VkovJnd6Df+QVqT+vT33OXCp8QUd7kA7RZrHWxb1B36OQKI/0gOA==", + "version": "1.0.30001769", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", "funding": [ { "type": "opencollective", @@ -5330,9 +5441,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.113", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.113.tgz", - "integrity": "sha512-wjT2O4hX+wdWPJ76gWSkMhcHAV2PTMX+QetUCPYEdCIe+cxmgzzSSiGRCKW8nuh4mwKZlpv0xvoW7OF2X+wmHg==", + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -8655,6 +8766,12 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/morgan": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", @@ -8756,9 +8873,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "license": "MIT" }, "node_modules/nopt": { @@ -9884,6 +10001,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regexparam": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz", + "integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/remix-auth": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/remix-auth/-/remix-auth-4.1.0.tgz", @@ -11272,9 +11398,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "funding": [ { "type": "opencollective", @@ -11717,6 +11843,20 @@ "node": ">=0.10.0" } }, + "node_modules/wouter": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/wouter/-/wouter-3.9.0.tgz", + "integrity": "sha512-sF/od/PIgqEQBQcrN7a2x3MX6MQE6nW0ygCfy9hQuUkuB28wEZuu/6M5GyqkrrEu9M6jxdkgE12yDFsQMKos4Q==", + "license": "Unlicense", + "dependencies": { + "mitt": "^3.0.1", + "regexparam": "^3.0.0", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index a013950..51aaa77 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,7 +35,8 @@ "remix-auth": "^4.1.0", "remix-auth-form": "^3.0.0", "shiki": "^3.12.2", - "use-debounce": "^10.0.4" + "use-debounce": "^10.0.4", + "wouter": "^3.9.0" }, "devDependencies": { "@biomejs/biome": "^1.9.4", @@ -46,6 +47,7 @@ "@types/node": "^22.13.10", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.1.4", "eslint": "^9.22.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.4", diff --git a/frontend/react-router.config.ts b/frontend/react-router.config.ts deleted file mode 100644 index 10392e4..0000000 --- a/frontend/react-router.config.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Config } from "@react-router/dev/config"; - -export default { - basename: process.env.ALBATROSS_BASE_PATH || "/", -} satisfies Config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 9575a13..41d9f36 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,10 +1,5 @@ { - "include": [ - "**/*", - "**/.server/**/*", - "**/.client/**/*", - ".react-router/types/**/*" - ], + "include": ["**/*"], "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ES2022"], "types": ["node", "vite/client"], @@ -24,7 +19,6 @@ "paths": { "~/*": ["./app/*"] }, - "rootDirs": [".", "./.react-router/types"], "noEmit": true } } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index b9b2873..33cb5cd 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,9 +1,9 @@ -import { reactRouter } from "@react-router/dev/vite"; import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ base: process.env.ALBATROSS_BASE_PATH || "/", - plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], + plugins: [tailwindcss(), react(), tsconfigPaths()], }); |
