From e239fe743fc66a8712cf9886d3dfed3cc41fce36 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Fri, 13 Feb 2026 22:40:45 +0900 Subject: 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 --- frontend/app/pages/DashboardPage.tsx | 108 +++++++++ frontend/app/pages/GolfPlayPage.tsx | 74 ++++++ frontend/app/pages/GolfWatchPage.tsx | 78 +++++++ frontend/app/pages/IndexPage.tsx | 39 ++++ frontend/app/pages/LoginPage.tsx | 101 ++++++++ frontend/app/pages/TournamentPage.tsx | 429 ++++++++++++++++++++++++++++++++++ 6 files changed, 829 insertions(+) create mode 100644 frontend/app/pages/DashboardPage.tsx create mode 100644 frontend/app/pages/GolfPlayPage.tsx create mode 100644 frontend/app/pages/GolfWatchPage.tsx create mode 100644 frontend/app/pages/IndexPage.tsx create mode 100644 frontend/app/pages/LoginPage.tsx create mode 100644 frontend/app/pages/TournamentPage.tsx (limited to 'frontend/app/pages') diff --git a/frontend/app/pages/DashboardPage.tsx b/frontend/app/pages/DashboardPage.tsx new file mode 100644 index 0000000..c81014d --- /dev/null +++ b/frontend/app/pages/DashboardPage.tsx @@ -0,0 +1,108 @@ +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"; + +type Game = components["schemas"]["Game"]; + +export default function DashboardPage() { + usePageTitle(`Dashboard | ${APP_NAME}`); + + const { user, logout } = useAuth(); + const [, navigate] = useLocation(); + + const [games, setGames] = useState([]); + 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)); + }, []); + + function handleLogout() { + logout(); + navigate("/"); + } + + if (loading) { + return ( +
+

Loading...

+
+ ); + } + + return ( +
+ {user?.icon_path && ( + + )} +

{user?.display_name}

+ +
+ {games.length === 0 ? ( +

エントリーできる試合はありません

+ ) : ( +
    + {games.map((game) => ( +
  • +
    + + {game.display_name} + +
    +
    + + 対戦 + + + 観戦 + +
    +
  • + ))} +
+ )} +
+
+ + {user?.is_admin && ( + + 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(null); + const [gameState, setGameState] = useState(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 ( +
+

Loading...

+
+ ); + } + + const token = getToken()!; + + return ( + + + + + + ); +} 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(null); + const [ranking, setRanking] = useState([]); + 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 ( +
+

Loading...

+
+ ); + } + + const token = getToken()!; + + return ( + + + + + + ); +} diff --git a/frontend/app/pages/IndexPage.tsx b/frontend/app/pages/IndexPage.tsx new file mode 100644 index 0000000..088cdc5 --- /dev/null +++ b/frontend/app/pages/IndexPage.tsx @@ -0,0 +1,39 @@ +import BorderedContainer from "../components/BorderedContainer"; +import NavigateLink from "../components/NavigateLink"; +import { APP_NAME, BASE_PATH } from "../config"; +import { usePageTitle } from "../hooks/usePageTitle"; + +export default function IndexPage() { + usePageTitle(APP_NAME); + + return ( +
+ iOSDC Japan 2025 +
+
+
Swift Code Battle
+
+
+
+ +

+ Swift コードバトルは指示された動作をする Swift + コードをより短く書けた方が勝ち、という 1 対 1 + の対戦コンテンツです。9/6 + に実施された予選を勝ち抜いたプレイヤーによるトーナメント形式での + コードバトルを 9/19 (金) day0 + に実施します。ここでは短いコードが正義です! + 可読性も保守性も放り投げた、イベントならではのコードをお楽しみください! +

+
+
+
+ ログイン +
+
+ ); +} 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(null); + const [fieldErrors, setFieldErrors] = useState<{ + username?: string; + password?: string; + }>({}); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(e: FormEvent) { + 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 ( +
+
+ +
+

+ fortee アカウントでログイン +

+ {error &&

{error}

} +
+ + + {fieldErrors.username && ( +

{fieldErrors.username}

+ )} +
+
+ + + {fieldErrors.password && ( +

{fieldErrors.password}

+ )} +
+
+ + {submitting ? "ログイン中..." : "ログイン"} + +
+
+
+
+
+ ); +} diff --git a/frontend/app/pages/TournamentPage.tsx b/frontend/app/pages/TournamentPage.tsx new file mode 100644 index 0000000..43ea790 --- /dev/null +++ b/frontend/app/pages/TournamentPage.tsx @@ -0,0 +1,429 @@ +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"; +import { usePageTitle } from "../hooks/usePageTitle"; + +type TournamentMatch = components["schemas"]["TournamentMatch"]; +type User = components["schemas"]["User"]; + +function Player({ player, rank }: { player: User | null; rank: number }) { + return ( + +
+ 予選 {rank} 位 + {player?.display_name} + {player?.icon_path && ( + + )} +
+
+ ); +} + +function BranchVL({ className = "" }: { className?: string }) { + return ( +
+
+
+
+ ); +} + +function BranchVR({ className = "" }: { className?: string }) { + return ( +
+
+
+
+ ); +} + +function BranchVL2({ + score, + className = "", +}: { score: number | null; className?: string }) { + return ( +
+
+
+ {score} +
+
+
+ ); +} + +function BranchVR2({ + score, + className = "", +}: { score: number | null; className?: string }) { + return ( +
+
+
+ {score} +
+
+
+ ); +} + +function BranchV3({ className = "" }: { className?: string }) { + return
; +} + +function BranchH({ + score, + className1, + className2, + className3, +}: { + score?: number | null; + className1: string; + className2: string; + className3: string; +}) { + return ( +
+
+
+
+ {score} +
+
+ ); +} + +function BranchH2({ + score, + className1, + className2, + className3, +}: { + score?: number | null; + className1: string; + className2: string; + className3: string; +}) { + return ( +
+
+ {score} +
+
+
+
+ ); +} + +function BranchL({ + score, + className = "", +}: { score: number | null; className?: string }) { + return ( +
+
+
+ {score} +
+
+ ); +} + +function BranchR({ + score, + className = "", +}: { score: number | null; className?: string }) { + return ( +
+
+ {score} +
+
+
+ ); +} + +function BranchL2({ className = "" }: { className?: string }) { + return ( +
+
+
+
+ ); +} + +function BranchR2({ className = "" }: { className?: string }) { + return ( +
+
+
+
+ ); +} + +function getPlayer(match: TournamentMatch, playerID: number): User | null { + if (match.player1?.user_id === playerID) return match.player1; + if (match.player2?.user_id === playerID) return match.player2; + return null; +} + +function getScore(match: TournamentMatch, playerIDs: number[]): number | null { + if (match.player1 && playerIDs.includes(match.player1.user_id)) + return match.player1_score ?? null; + if (match.player2 && playerIDs.includes(match.player2.user_id)) + return match.player2_score ?? null; + return null; +} + +function getBorderColor(match: TournamentMatch, playerIDs: number[]): string { + if (!match.winner) { + return "border-black"; + } + if (playerIDs.includes(match.winner)) { + return "border-pink-700"; + } + return "border-gray-400"; +} + +export default function TournamentPage() { + usePageTitle(`Tournament | ${APP_NAME}`); + + const [tournament, setTournament] = useState<{ + matches: TournamentMatch[]; + } | null>(null); + const [playerIDs, setPlayerIDs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+

Loading...

+
+ ); + } + + if (error || !tournament) { + return ( +
+

{error || "Failed to load tournament"}

+
+ ); + } + + const match1 = tournament.matches[0]!; + const match2 = tournament.matches[1]!; + const match3 = tournament.matches[2]!; + const match4 = tournament.matches[3]!; + const match5 = tournament.matches[4]!; + + const playerID1 = playerIDs[0]!; + const playerID2 = playerIDs[1]!; + const playerID3 = playerIDs[2]!; + const playerID4 = playerIDs[3]!; + const playerID5 = playerIDs[4]!; + const playerID6 = playerIDs[5]!; + + const player5 = getPlayer(match1, playerID5); + const player4 = getPlayer(match1, playerID4); + const player3 = getPlayer(match2, playerID3); + const player6 = getPlayer(match2, playerID6); + const player1 = getPlayer(match3, playerID1); + const player2 = getPlayer(match4, playerID2); + + return ( +
+
+

+ iOSDC Japan 2025 Swift Code Battle +

+ +
+
+
+
+ +
+
+
+
+
+
+ + + + +
+
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+
+
+ ); +} -- cgit v1.3.1