aboutsummaryrefslogtreecommitdiffhomepage
path: root/frontend/app
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-13 23:08:50 +0900
committernsfisis <nsfisis@gmail.com>2026-02-13 23:08:50 +0900
commit470b7235b80d082009ad350e2b33ef6637209e02 (patch)
tree60ffe938a4051255ea0d6b35001be50c28b76497 /frontend/app
parent482c3a52a0fcc5870a7db4a190475caf61b211a3 (diff)
parent6c30f383a65cb000d66a85cadc96253ce7061942 (diff)
downloadphperkaigi-2026-albatross-470b7235b80d082009ad350e2b33ef6637209e02.tar.gz
phperkaigi-2026-albatross-470b7235b80d082009ad350e2b33ef6637209e02.tar.zst
phperkaigi-2026-albatross-470b7235b80d082009ad350e2b33ef6637209e02.zip
Merge branch 'feat/frontend-rearchitecture'
Diffstat (limited to 'frontend/app')
-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/api/client.ts9
-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
25 files changed, 616 insertions, 608 deletions
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/api/client.ts b/frontend/app/api/client.ts
index 6b7ce80..86f2506 100644
--- a/frontend/app/api/client.ts
+++ b/frontend/app/api/client.ts
@@ -3,11 +3,12 @@ import { createContext } from "react";
import { API_BASE_PATH } from "../config";
import type { paths } from "./schema";
+const apiOrigin =
+ import.meta.env.VITE_API_BASE_URL ??
+ (import.meta.env.DEV ? "http://localhost:8004" : "");
+
const client = createClient<paths>({
- baseUrl:
- process.env.NODE_ENV === "development"
- ? `http://localhost:8004${API_BASE_PATH}`
- : `https://t.nil.ninja${API_BASE_PATH}`,
+ baseUrl: `${apiOrigin}${API_BASE_PATH}`,
});
export async function apiLogin(username: string, password: string) {
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;
-}