diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-13 23:08:50 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-13 23:08:50 +0900 |
| commit | 470b7235b80d082009ad350e2b33ef6637209e02 (patch) | |
| tree | 60ffe938a4051255ea0d6b35001be50c28b76497 /frontend/app | |
| parent | 482c3a52a0fcc5870a7db4a190475caf61b211a3 (diff) | |
| parent | 6c30f383a65cb000d66a85cadc96253ce7061942 (diff) | |
| download | phperkaigi-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')
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; -} |
