aboutsummaryrefslogtreecommitdiffhomepage
path: root/frontend
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-13 22:40:45 +0900
committernsfisis <nsfisis@gmail.com>2026-02-13 23:07:26 +0900
commite239fe743fc66a8712cf9886d3dfed3cc41fce36 (patch)
treee3452fb13dce114cea0e8371dbb049118aa1229e /frontend
parent482c3a52a0fcc5870a7db4a190475caf61b211a3 (diff)
downloadphperkaigi-2026-albatross-e239fe743fc66a8712cf9886d3dfed3cc41fce36.tar.gz
phperkaigi-2026-albatross-e239fe743fc66a8712cf9886d3dfed3cc41fce36.tar.zst
phperkaigi-2026-albatross-e239fe743fc66a8712cf9886d3dfed3cc41fce36.zip
refactor(frontend): replace React Router BFF with Wouter SPA
Remove React Router 7 SSR/BFF architecture (server-side loaders, actions, sessions, remix-auth) and replace with a client-side SPA using Wouter for routing and cookie-based JWT auth. - Replace reactRouter() Vite plugin with @vitejs/plugin-react - Add index.html + app/main.tsx as SPA entry points - Add Wouter routing with auth guards (ProtectedRoute/PublicOnlyRoute) - Add client-side auth (app/auth.ts) and useAuth hook - Migrate all route files to app/pages/ with client-side data fetching - Update NavigateLink and GolfPlayAppGaming to use Wouter Link - Remove .server/, routes/, root.tsx, react-router.config.ts - Clean up tsconfig.json (remove .react-router references) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'frontend')
-rw-r--r--frontend/.gitignore2
-rw-r--r--frontend/app/.server/auth.ts96
-rw-r--r--frontend/app/.server/cookie.ts44
-rw-r--r--frontend/app/.server/session.ts50
-rw-r--r--frontend/app/App.tsx58
-rw-r--r--frontend/app/auth.ts45
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx2
-rw-r--r--frontend/app/components/NavigateLink.tsx16
-rw-r--r--frontend/app/components/ProtectedRoute.tsx16
-rw-r--r--frontend/app/components/PublicOnlyRoute.tsx16
-rw-r--r--frontend/app/hooks/useAuth.ts58
-rw-r--r--frontend/app/hooks/usePageTitle.ts7
-rw-r--r--frontend/app/main.tsx20
-rw-r--r--frontend/app/pages/DashboardPage.tsx (renamed from frontend/app/routes/dashboard.tsx)74
-rw-r--r--frontend/app/pages/GolfPlayPage.tsx74
-rw-r--r--frontend/app/pages/GolfWatchPage.tsx78
-rw-r--r--frontend/app/pages/IndexPage.tsx (renamed from frontend/app/routes/_index.tsx)12
-rw-r--r--frontend/app/pages/LoginPage.tsx101
-rw-r--r--frontend/app/pages/TournamentPage.tsx (renamed from frontend/app/routes/tournament.tsx)161
-rw-r--r--frontend/app/root.tsx36
-rw-r--r--frontend/app/routes.ts4
-rw-r--r--frontend/app/routes/golf.$gameId.play.tsx62
-rw-r--r--frontend/app/routes/golf.$gameId.watch.tsx63
-rw-r--r--frontend/app/routes/login.tsx115
-rw-r--r--frontend/app/routes/logout.tsx7
-rw-r--r--frontend/index.html13
-rw-r--r--frontend/package-lock.json442
-rw-r--r--frontend/package.json4
-rw-r--r--frontend/react-router.config.ts5
-rw-r--r--frontend/tsconfig.json8
-rw-r--r--frontend/vite.config.ts4
31 files changed, 922 insertions, 771 deletions
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()],
});