diff options
Diffstat (limited to 'frontend/app/pages')
| -rw-r--r-- | frontend/app/pages/DashboardPage.tsx | 202 | ||||
| -rw-r--r-- | frontend/app/pages/GolfPlayPage.tsx | 94 | ||||
| -rw-r--r-- | frontend/app/pages/GolfProblemPreviewPage.tsx | 90 | ||||
| -rw-r--r-- | frontend/app/pages/GolfWatchPage.tsx | 116 | ||||
| -rw-r--r-- | frontend/app/pages/IndexPage.tsx | 62 | ||||
| -rw-r--r-- | frontend/app/pages/LoginPage.tsx | 166 | ||||
| -rw-r--r-- | frontend/app/pages/TournamentPage.test.tsx | 78 | ||||
| -rw-r--r-- | frontend/app/pages/TournamentPage.tsx | 510 |
8 files changed, 659 insertions, 659 deletions
diff --git a/frontend/app/pages/DashboardPage.tsx b/frontend/app/pages/DashboardPage.tsx index 54bfdd6..74be96e 100644 --- a/frontend/app/pages/DashboardPage.tsx +++ b/frontend/app/pages/DashboardPage.tsx @@ -12,111 +12,111 @@ import { usePageTitle } from "../hooks/usePageTitle"; type Game = components["schemas"]["Game"]; export default function DashboardPage() { - usePageTitle(`Dashboard | ${APP_NAME}`); + usePageTitle(`Dashboard | ${APP_NAME}`); - const { user, isLoggedIn, isLoading: authLoading, logout } = useAuth(); - const [, navigate] = useLocation(); + const { user, isLoggedIn, isLoading: authLoading, logout } = useAuth(); + const [, navigate] = useLocation(); - const [games, setGames] = useState<Game[]>([]); - const [loading, setLoading] = useState(true); + const [games, setGames] = useState<Game[]>([]); + const [loading, setLoading] = useState(true); - useEffect(() => { - const apiClient = createApiClient(); - apiClient - .getGames() - .then(({ games }) => setGames(games)) - .finally(() => setLoading(false)); - }, []); + useEffect(() => { + const apiClient = createApiClient(); + apiClient + .getGames() + .then(({ games }) => setGames(games)) + .finally(() => setLoading(false)); + }, []); - async function handleLogout() { - await logout(); - navigate("/"); - } + async function handleLogout() { + await logout(); + navigate("/"); + } - if (loading || authLoading) { - return ( - <div className="min-h-screen bg-gray-100 flex items-center justify-center"> - <p className="text-gray-500">Loading...</p> - </div> - ); - } + if (loading || authLoading) { + 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"> - {isLoggedIn && user?.icon_path && ( - <UserIcon - iconPath={user.icon_path} - displayName={user.display_name} - className="w-24 h-24" - /> - )} - {isLoggedIn ? ( - <h1 className="text-3xl font-bold text-gray-800"> - {user?.display_name} - </h1> - ) : ( - <h1 className="text-3xl font-bold text-gray-800">試合一覧</h1> - )} - <BorderedContainerWithCaption caption="試合一覧"> - <div className="px-4"> - {games.length === 0 ? ( - <p>試合はありません</p> - ) : ( - <ul className="divide-y divide-gray-300"> - {games.map((game) => ( - <li - key={game.game_id} - className="flex justify-between items-center py-2 gap-4" - > - <div> - <span className="font-medium text-gray-800"> - {game.display_name} - </span> - </div> - <div className="flex gap-2"> - {isLoggedIn && game.started_at == null && ( - <NavigateLink to={`/golf/${game.game_id}/preview`}> - 問題を見る - </NavigateLink> - )} - {isLoggedIn && ( - <NavigateLink to={`/golf/${game.game_id}/play`}> - 対戦 - </NavigateLink> - )} - <NavigateLink to={`/golf/${game.game_id}/watch`}> - 観戦 - </NavigateLink> - </div> - </li> - ))} - </ul> - )} - </div> - </BorderedContainerWithCaption> - {isLoggedIn ? ( - <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> - ) : ( - <NavigateLink to="/login">ログイン</NavigateLink> - )} - {isLoggedIn && user?.is_admin && ( - <a - href={ - import.meta.env.DEV - ? `http://localhost:8007${BASE_PATH}admin/dashboard` - : `${BASE_PATH}admin/dashboard` - } - className="text-lg text-white bg-brand-600 px-4 py-2 rounded-sm transition duration-300 hover:bg-brand-500 focus:ring-3 focus:ring-brand-400 focus:outline-hidden" - > - Admin Dashboard - </a> - )} - </div> - ); + return ( + <div className="p-6 bg-gray-100 min-h-screen flex flex-col items-center gap-4"> + {isLoggedIn && user?.icon_path && ( + <UserIcon + iconPath={user.icon_path} + displayName={user.display_name} + className="w-24 h-24" + /> + )} + {isLoggedIn ? ( + <h1 className="text-3xl font-bold text-gray-800"> + {user?.display_name} + </h1> + ) : ( + <h1 className="text-3xl font-bold text-gray-800">試合一覧</h1> + )} + <BorderedContainerWithCaption caption="試合一覧"> + <div className="px-4"> + {games.length === 0 ? ( + <p>試合はありません</p> + ) : ( + <ul className="divide-y divide-gray-300"> + {games.map((game) => ( + <li + key={game.game_id} + className="flex justify-between items-center py-2 gap-4" + > + <div> + <span className="font-medium text-gray-800"> + {game.display_name} + </span> + </div> + <div className="flex gap-2"> + {isLoggedIn && game.started_at == null && ( + <NavigateLink to={`/golf/${game.game_id}/preview`}> + 問題を見る + </NavigateLink> + )} + {isLoggedIn && ( + <NavigateLink to={`/golf/${game.game_id}/play`}> + 対戦 + </NavigateLink> + )} + <NavigateLink to={`/golf/${game.game_id}/watch`}> + 観戦 + </NavigateLink> + </div> + </li> + ))} + </ul> + )} + </div> + </BorderedContainerWithCaption> + {isLoggedIn ? ( + <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> + ) : ( + <NavigateLink to="/login">ログイン</NavigateLink> + )} + {isLoggedIn && user?.is_admin && ( + <a + href={ + import.meta.env.DEV + ? `http://localhost:8007${BASE_PATH}admin/dashboard` + : `${BASE_PATH}admin/dashboard` + } + className="text-lg text-white bg-brand-600 px-4 py-2 rounded-sm transition duration-300 hover:bg-brand-500 focus:ring-3 focus:ring-brand-400 focus:outline-hidden" + > + Admin Dashboard + </a> + )} + </div> + ); } diff --git a/frontend/app/pages/GolfPlayPage.tsx b/frontend/app/pages/GolfPlayPage.tsx index 49f47f6..ff6273d 100644 --- a/frontend/app/pages/GolfPlayPage.tsx +++ b/frontend/app/pages/GolfPlayPage.tsx @@ -12,58 +12,58 @@ type Game = components["schemas"]["Game"]; type LatestGameState = components["schemas"]["LatestGameState"]; export default function GolfPlayPage({ gameId }: { gameId: string }) { - const { user } = useAuth(); - const [, navigate] = useLocation(); + 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 [game, setGame] = useState<Game | null>(null); + const [gameState, setGameState] = useState<LatestGameState | null>(null); + const [loading, setLoading] = useState(true); - const gameIdNum = Number(gameId); + const gameIdNum = Number(gameId); - usePageTitle( - game - ? `Golf Playing ${game.display_name} | ${APP_NAME}` - : `Golf Playing | ${APP_NAME}`, - ); + usePageTitle( + game + ? `Golf Playing ${game.display_name} | ${APP_NAME}` + : `Golf Playing | ${APP_NAME}`, + ); - useEffect(() => { - const apiClient = createApiClient(); - Promise.all([ - apiClient.getGame(gameIdNum), - apiClient.getGamePlayLatestState(gameIdNum), - ]) - .then(([{ game }, { state }]) => { - setGame(game); - setGameState(state); - }) - .catch(() => navigate("/dashboard")) - .finally(() => setLoading(false)); - }, [gameIdNum, navigate]); + useEffect(() => { + const apiClient = createApiClient(); + 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]); + 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> - ); - } + 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> + ); + } - return ( - <JotaiProvider store={store}> - <ApiClientContext.Provider value={createApiClient()}> - <GolfPlayApp - key={game.game_id} - game={game} - player={user} - initialGameState={gameState} - /> - </ApiClientContext.Provider> - </JotaiProvider> - ); + return ( + <JotaiProvider store={store}> + <ApiClientContext.Provider value={createApiClient()}> + <GolfPlayApp + key={game.game_id} + game={game} + player={user} + initialGameState={gameState} + /> + </ApiClientContext.Provider> + </JotaiProvider> + ); } diff --git a/frontend/app/pages/GolfProblemPreviewPage.tsx b/frontend/app/pages/GolfProblemPreviewPage.tsx index 4a84809..9eee691 100644 --- a/frontend/app/pages/GolfProblemPreviewPage.tsx +++ b/frontend/app/pages/GolfProblemPreviewPage.tsx @@ -10,54 +10,54 @@ import { usePageTitle } from "../hooks/usePageTitle"; type Game = components["schemas"]["Game"]; export default function GolfProblemPreviewPage({ gameId }: { gameId: string }) { - const [, navigate] = useLocation(); - const [game, setGame] = useState<Game | null>(null); - const [loading, setLoading] = useState(true); + const [, navigate] = useLocation(); + const [game, setGame] = useState<Game | null>(null); + const [loading, setLoading] = useState(true); - const gameIdNum = Number(gameId); + const gameIdNum = Number(gameId); - usePageTitle( - game - ? `${game.display_name} - 問題プレビュー | ${APP_NAME}` - : `問題プレビュー | ${APP_NAME}`, - ); + usePageTitle( + game + ? `${game.display_name} - 問題プレビュー | ${APP_NAME}` + : `問題プレビュー | ${APP_NAME}`, + ); - useEffect(() => { - const apiClient = createApiClient(); - apiClient - .getGame(gameIdNum) - .then(({ game }) => setGame(game)) - .catch(() => navigate("/dashboard")) - .finally(() => setLoading(false)); - }, [gameIdNum, navigate]); + useEffect(() => { + const apiClient = createApiClient(); + apiClient + .getGame(gameIdNum) + .then(({ game }) => setGame(game)) + .catch(() => navigate("/dashboard")) + .finally(() => setLoading(false)); + }, [gameIdNum, navigate]); - if (loading || !game) { - return ( - <div className="min-h-screen bg-gray-100 flex items-center justify-center"> - <p className="text-gray-500">Loading...</p> - </div> - ); - } + if (loading || !game) { + 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"> - <h1 className="text-3xl font-bold text-gray-800">{game.display_name}</h1> - <div className="w-full max-w-3xl flex flex-col gap-4"> - <ProblemColumnContent - description={game.problem.description} - language={game.problem.language} - sampleCode={game.problem.sample_code} - /> - </div> - <div className="flex gap-4"> - <NavigateLink to={`/golf/${game.game_id}/play`}> - 対戦ページへ - </NavigateLink> - <NavigateLink to={`/golf/${game.game_id}/watch`}> - 観戦ページへ - </NavigateLink> - </div> - <NavigateLink to="/dashboard">ダッシュボードへ戻る</NavigateLink> - </div> - ); + return ( + <div className="p-6 bg-gray-100 min-h-screen flex flex-col items-center gap-4"> + <h1 className="text-3xl font-bold text-gray-800">{game.display_name}</h1> + <div className="w-full max-w-3xl flex flex-col gap-4"> + <ProblemColumnContent + description={game.problem.description} + language={game.problem.language} + sampleCode={game.problem.sample_code} + /> + </div> + <div className="flex gap-4"> + <NavigateLink to={`/golf/${game.game_id}/play`}> + 対戦ページへ + </NavigateLink> + <NavigateLink to={`/golf/${game.game_id}/watch`}> + 観戦ページへ + </NavigateLink> + </div> + <NavigateLink to="/dashboard">ダッシュボードへ戻る</NavigateLink> + </div> + ); } diff --git a/frontend/app/pages/GolfWatchPage.tsx b/frontend/app/pages/GolfWatchPage.tsx index 168bd6f..013e1a0 100644 --- a/frontend/app/pages/GolfWatchPage.tsx +++ b/frontend/app/pages/GolfWatchPage.tsx @@ -11,69 +11,69 @@ type LatestGameState = components["schemas"]["LatestGameState"]; type RankingEntry = components["schemas"]["RankingEntry"]; export default function GolfWatchPage({ gameId }: { gameId: string }) { - 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 [error, setError] = useState(false); + 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 [error, setError] = useState(false); - const gameIdNum = Number(gameId); + const gameIdNum = Number(gameId); - usePageTitle( - game - ? `Golf Watching ${game.display_name} | ${APP_NAME}` - : `Golf Watching | ${APP_NAME}`, - ); + usePageTitle( + game + ? `Golf Watching ${game.display_name} | ${APP_NAME}` + : `Golf Watching | ${APP_NAME}`, + ); - useEffect(() => { - const apiClient = createApiClient(); - Promise.all([ - apiClient.getGame(gameIdNum), - apiClient.getGameWatchRanking(gameIdNum), - apiClient.getGameWatchLatestStates(gameIdNum), - ]) - .then(([{ game }, { ranking }, { states }]) => { - setGame(game); - setRanking(ranking); - setGameStates(states); - }) - .catch(() => setError(true)) - .finally(() => setLoading(false)); - }, [gameIdNum]); + useEffect(() => { + const apiClient = createApiClient(); + Promise.all([ + apiClient.getGame(gameIdNum), + apiClient.getGameWatchRanking(gameIdNum), + apiClient.getGameWatchLatestStates(gameIdNum), + ]) + .then(([{ game }, { ranking }, { states }]) => { + setGame(game); + setRanking(ranking); + setGameStates(states); + }) + .catch(() => setError(true)) + .finally(() => setLoading(false)); + }, [gameIdNum]); - const store = useMemo(() => { - if (!game) return null; - return createStore(); - }, [game]); + const store = useMemo(() => { + if (!game) return null; + return createStore(); + }, [game]); - 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 (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 || !game || !store) { - return ( - <div className="min-h-screen bg-gray-100 flex items-center justify-center"> - <p className="text-red-500">試合が見つかりませんでした</p> - </div> - ); - } + if (error || !game || !store) { + return ( + <div className="min-h-screen bg-gray-100 flex items-center justify-center"> + <p className="text-red-500">試合が見つかりませんでした</p> + </div> + ); + } - return ( - <JotaiProvider store={store}> - <ApiClientContext.Provider value={createApiClient()}> - <GolfWatchApp - key={game.game_id} - game={game} - initialGameStates={gameStates} - initialRanking={ranking} - /> - </ApiClientContext.Provider> - </JotaiProvider> - ); + return ( + <JotaiProvider store={store}> + <ApiClientContext.Provider value={createApiClient()}> + <GolfWatchApp + key={game.game_id} + game={game} + initialGameStates={gameStates} + initialRanking={ranking} + /> + </ApiClientContext.Provider> + </JotaiProvider> + ); } diff --git a/frontend/app/pages/IndexPage.tsx b/frontend/app/pages/IndexPage.tsx index 8dfbefe..a3aa9f1 100644 --- a/frontend/app/pages/IndexPage.tsx +++ b/frontend/app/pages/IndexPage.tsx @@ -4,36 +4,36 @@ import { APP_NAME, BASE_PATH } from "../config"; import { usePageTitle } from "../hooks/usePageTitle"; export default function IndexPage() { - usePageTitle(APP_NAME); + usePageTitle(APP_NAME); - return ( - <div className="min-h-screen bg-gray-100 flex flex-col items-center justify-center gap-y-6"> - <img - src={`${BASE_PATH}logo.svg`} - alt="PHPerKaigi 2026" - className="w-96 h-auto" - /> - <div className="text-center"> - <div className="font-bold text-transparent bg-clip-text bg-brand-600"> - <div className="text-6xl">PHPER CODE BATTLE</div> - </div> - </div> - <div className="mx-2"> - <BorderedContainer> - <p className="text-gray-900 max-w-prose"> - PHPer コードバトルは指示された動作をする PHP - コードをより短く書けた方が勝ち、という 1 対 1 の対戦コンテンツです。 - 3/20 の day 0 と 3/21 の day 1 では、2/28 - に実施されたオフライン予選と、当日まで開催しているオンライン予選を勝ち抜いたプレイヤーによるトーナメント形式での - PHPer コードバトルを実施します。ここでは短いコードが正義です! - 可読性も保守性も放り投げた、イベントならではのコードをお楽しみください! - </p> - </BorderedContainer> - </div> - <div className="flex gap-4"> - <NavigateLink to="/dashboard">観戦する</NavigateLink> - <NavigateLink to="/login">ログイン</NavigateLink> - </div> - </div> - ); + return ( + <div className="min-h-screen bg-gray-100 flex flex-col items-center justify-center gap-y-6"> + <img + src={`${BASE_PATH}logo.svg`} + alt="PHPerKaigi 2026" + className="w-96 h-auto" + /> + <div className="text-center"> + <div className="font-bold text-transparent bg-clip-text bg-brand-600"> + <div className="text-6xl">PHPER CODE BATTLE</div> + </div> + </div> + <div className="mx-2"> + <BorderedContainer> + <p className="text-gray-900 max-w-prose"> + PHPer コードバトルは指示された動作をする PHP + コードをより短く書けた方が勝ち、という 1 対 1 の対戦コンテンツです。 + 3/20 の day 0 と 3/21 の day 1 では、2/28 + に実施されたオフライン予選と、当日まで開催しているオンライン予選を勝ち抜いたプレイヤーによるトーナメント形式での + PHPer コードバトルを実施します。ここでは短いコードが正義です! + 可読性も保守性も放り投げた、イベントならではのコードをお楽しみください! + </p> + </BorderedContainer> + </div> + <div className="flex gap-4"> + <NavigateLink to="/dashboard">観戦する</NavigateLink> + <NavigateLink to="/login">ログイン</NavigateLink> + </div> + </div> + ); } diff --git a/frontend/app/pages/LoginPage.tsx b/frontend/app/pages/LoginPage.tsx index 139b1f0..4130518 100644 --- a/frontend/app/pages/LoginPage.tsx +++ b/frontend/app/pages/LoginPage.tsx @@ -8,94 +8,94 @@ import { useAuth } from "../hooks/useAuth"; import { usePageTitle } from "../hooks/usePageTitle"; export default function LoginPage() { - usePageTitle(`Login | ${APP_NAME}`); + usePageTitle(`Login | ${APP_NAME}`); - const { login } = useAuth(); - const [, navigate] = useLocation(); + 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); + 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")); + 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; - } + 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({}); + setSubmitting(true); + setError(null); + setFieldErrors({}); - try { - await login(username, password); - navigate("/dashboard"); - } catch (err) { - setError(err instanceof Error ? err.message : "ログインに失敗しました"); - } finally { - setSubmitting(false); - } - } + 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-brand-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> - ); + 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-brand-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/pages/TournamentPage.test.tsx b/frontend/app/pages/TournamentPage.test.tsx index 3c6f116..e9e4987 100644 --- a/frontend/app/pages/TournamentPage.test.tsx +++ b/frontend/app/pages/TournamentPage.test.tsx @@ -6,55 +6,55 @@ import { afterEach, describe, expect, test } from "vitest"; import TournamentPage, { standardBracketSeedsForTest } from "./TournamentPage"; afterEach(() => { - cleanup(); + cleanup(); }); describe("standardBracketSeeds", () => { - test("bracket_size=2 returns [1, 2]", () => { - const seeds = standardBracketSeedsForTest(2); - expect(seeds).toEqual([1, 2]); - }); + test("bracket_size=2 returns [1, 2]", () => { + const seeds = standardBracketSeedsForTest(2); + expect(seeds).toEqual([1, 2]); + }); - test("bracket_size=4 returns [1, 4, 2, 3]", () => { - const seeds = standardBracketSeedsForTest(4); - expect(seeds).toEqual([1, 4, 2, 3]); - }); + test("bracket_size=4 returns [1, 4, 2, 3]", () => { + const seeds = standardBracketSeedsForTest(4); + expect(seeds).toEqual([1, 4, 2, 3]); + }); - test("bracket_size=8 returns [1, 8, 4, 5, 2, 7, 3, 6]", () => { - const seeds = standardBracketSeedsForTest(8); - expect(seeds).toEqual([1, 8, 4, 5, 2, 7, 3, 6]); - }); + test("bracket_size=8 returns [1, 8, 4, 5, 2, 7, 3, 6]", () => { + const seeds = standardBracketSeedsForTest(8); + expect(seeds).toEqual([1, 8, 4, 5, 2, 7, 3, 6]); + }); - test("all seeds present for size 16", () => { - const seeds = standardBracketSeedsForTest(16); - expect(seeds).toHaveLength(16); - const sorted = [...seeds].sort((a, b) => a - b); - expect(sorted).toEqual(Array.from({ length: 16 }, (_, i) => i + 1)); - }); + test("all seeds present for size 16", () => { + const seeds = standardBracketSeedsForTest(16); + expect(seeds).toHaveLength(16); + const sorted = [...seeds].sort((a, b) => a - b); + expect(sorted).toEqual(Array.from({ length: 16 }, (_, i) => i + 1)); + }); - test("seed 1 and seed 2 on opposite sides for size 8", () => { - const seeds = standardBracketSeedsForTest(8); - const pos1 = seeds.indexOf(1); - const pos2 = seeds.indexOf(2); - // Seed 1 in first half (0-3), Seed 2 in second half (4-7) - expect(pos1).toBeLessThan(4); - expect(pos2).toBeGreaterThanOrEqual(4); - }); + test("seed 1 and seed 2 on opposite sides for size 8", () => { + const seeds = standardBracketSeedsForTest(8); + const pos1 = seeds.indexOf(1); + const pos2 = seeds.indexOf(2); + // Seed 1 in first half (0-3), Seed 2 in second half (4-7) + expect(pos1).toBeLessThan(4); + expect(pos2).toBeGreaterThanOrEqual(4); + }); }); describe("TournamentPage", () => { - test("shows loading state initially", () => { - render(<TournamentPage tournamentId="1" />); - expect(screen.getByText("Loading...")).toBeDefined(); - }); + test("shows loading state initially", () => { + render(<TournamentPage tournamentId="1" />); + expect(screen.getByText("Loading...")).toBeDefined(); + }); - test("shows error for invalid tournament ID", () => { - render(<TournamentPage tournamentId="abc" />); - expect(screen.getByText("Invalid tournament ID")).toBeDefined(); - }); + test("shows error for invalid tournament ID", () => { + render(<TournamentPage tournamentId="abc" />); + expect(screen.getByText("Invalid tournament ID")).toBeDefined(); + }); - test("shows error for zero tournament ID", () => { - render(<TournamentPage tournamentId="0" />); - expect(screen.getByText("Invalid tournament ID")).toBeDefined(); - }); + test("shows error for zero tournament ID", () => { + render(<TournamentPage tournamentId="0" />); + expect(screen.getByText("Invalid tournament ID")).toBeDefined(); + }); }); diff --git a/frontend/app/pages/TournamentPage.tsx b/frontend/app/pages/TournamentPage.tsx index 0bf4895..f555ba0 100644 --- a/frontend/app/pages/TournamentPage.tsx +++ b/frontend/app/pages/TournamentPage.tsx @@ -11,304 +11,304 @@ type TournamentMatch = components["schemas"]["TournamentMatch"]; type TournamentEntry = components["schemas"]["TournamentEntry"]; function getBorderColor(match: TournamentMatch, userID?: number): string { - if (!match.winner_user_id) { - return "border-black"; - } - if (userID !== undefined && match.winner_user_id === userID) { - return "border-pink-700"; - } - return "border-gray-400"; + if (!match.winner_user_id) { + return "border-black"; + } + if (userID !== undefined && match.winner_user_id === userID) { + return "border-pink-700"; + } + return "border-gray-400"; } function PlayerCard({ entry }: { entry: TournamentEntry | undefined }) { - if (!entry) { - return ( - <div className="flex flex-col items-center gap-1 p-2 opacity-30"> - <span className="text-gray-400 text-sm">BYE</span> - </div> - ); - } - return ( - <BorderedContainer> - <div className="flex flex-col items-center gap-1"> - <span className="text-gray-600 text-xs">Seed {entry.seed}</span> - <span className="font-medium text-sm truncate max-w-full"> - {entry.user.display_name} - </span> - {entry.user.icon_path && ( - <UserIcon - iconPath={entry.user.icon_path} - displayName={entry.user.display_name} - className="w-12 h-12" - /> - )} - </div> - </BorderedContainer> - ); + if (!entry) { + return ( + <div className="flex flex-col items-center gap-1 p-2 opacity-30"> + <span className="text-gray-400 text-sm">BYE</span> + </div> + ); + } + return ( + <BorderedContainer> + <div className="flex flex-col items-center gap-1"> + <span className="text-gray-600 text-xs">Seed {entry.seed}</span> + <span className="font-medium text-sm truncate max-w-full"> + {entry.user.display_name} + </span> + {entry.user.icon_path && ( + <UserIcon + iconPath={entry.user.icon_path} + displayName={entry.user.display_name} + className="w-12 h-12" + /> + )} + </div> + </BorderedContainer> + ); } function MatchCell({ match }: { match: TournamentMatch }) { - if (match.is_bye) { - return ( - <div className="flex items-center justify-center h-full opacity-30"> - <span className="text-gray-400 text-xs">BYE</span> - </div> - ); - } + if (match.is_bye) { + return ( + <div className="flex items-center justify-center h-full opacity-30"> + <span className="text-gray-400 text-xs">BYE</span> + </div> + ); + } - const p1Color = match.winner_user_id - ? match.winner_user_id === match.player1?.user_id - ? "border-pink-700" - : "border-gray-400" - : "border-black"; - const p2Color = match.winner_user_id - ? match.winner_user_id === match.player2?.user_id - ? "border-pink-700" - : "border-gray-400" - : "border-black"; + const p1Color = match.winner_user_id + ? match.winner_user_id === match.player1?.user_id + ? "border-pink-700" + : "border-gray-400" + : "border-black"; + const p2Color = match.winner_user_id + ? match.winner_user_id === match.player2?.user_id + ? "border-pink-700" + : "border-gray-400" + : "border-black"; - return ( - <div className="flex flex-col gap-1 p-1"> - <div - className={`border-2 ${p1Color} rounded px-2 py-1 text-xs flex justify-between`} - > - <span className="truncate">{match.player1?.display_name ?? "?"}</span> - {match.player1_score !== undefined && ( - <span className="font-bold ml-1">{match.player1_score}</span> - )} - </div> - <div - className={`border-2 ${p2Color} rounded px-2 py-1 text-xs flex justify-between`} - > - <span className="truncate">{match.player2?.display_name ?? "?"}</span> - {match.player2_score !== undefined && ( - <span className="font-bold ml-1">{match.player2_score}</span> - )} - </div> - </div> - ); + return ( + <div className="flex flex-col gap-1 p-1"> + <div + className={`border-2 ${p1Color} rounded px-2 py-1 text-xs flex justify-between`} + > + <span className="truncate">{match.player1?.display_name ?? "?"}</span> + {match.player1_score !== undefined && ( + <span className="font-bold ml-1">{match.player1_score}</span> + )} + </div> + <div + className={`border-2 ${p2Color} rounded px-2 py-1 text-xs flex justify-between`} + > + <span className="truncate">{match.player2?.display_name ?? "?"}</span> + {match.player2_score !== undefined && ( + <span className="font-bold ml-1">{match.player2_score}</span> + )} + </div> + </div> + ); } function Connector({ - position, - colSpan, - match, + position, + colSpan, + match, }: { - position: number; - colSpan: number; - match: TournamentMatch | undefined; + position: number; + colSpan: number; + match: TournamentMatch | undefined; }) { - const leftHalf = colSpan / 2; - const rightHalf = colSpan - leftHalf; + const leftHalf = colSpan / 2; + const rightHalf = colSpan - leftHalf; - const leftColor = match - ? getBorderColor(match, match.player1?.user_id) - : "border-black"; - const rightColor = match - ? getBorderColor(match, match.player2?.user_id) - : "border-black"; + const leftColor = match + ? getBorderColor(match, match.player1?.user_id) + : "border-black"; + const rightColor = match + ? getBorderColor(match, match.player2?.user_id) + : "border-black"; - return ( - <div - className="grid h-8" - style={{ - gridColumn: `${position * colSpan + 1} / span ${colSpan}`, - }} - > - <div - className="grid" - style={{ - gridTemplateColumns: `repeat(${colSpan}, 1fr)`, - }} - > - <div - className={`border-t-4 border-r-2 ${leftColor}`} - style={{ gridColumn: `1 / span ${leftHalf}` }} - /> - <div - className={`border-t-4 border-l-2 ${rightColor}`} - style={{ gridColumn: `${leftHalf + 1} / span ${rightHalf}` }} - /> - </div> - </div> - ); + return ( + <div + className="grid h-8" + style={{ + gridColumn: `${position * colSpan + 1} / span ${colSpan}`, + }} + > + <div + className="grid" + style={{ + gridTemplateColumns: `repeat(${colSpan}, 1fr)`, + }} + > + <div + className={`border-t-4 border-r-2 ${leftColor}`} + style={{ gridColumn: `1 / span ${leftHalf}` }} + /> + <div + className={`border-t-4 border-l-2 ${rightColor}`} + style={{ gridColumn: `${leftHalf + 1} / span ${rightHalf}` }} + /> + </div> + </div> + ); } function TournamentBracket({ tournament }: { tournament: Tournament }) { - const { bracket_size, num_rounds, entries, matches } = tournament; + const { bracket_size, num_rounds, entries, matches } = tournament; - const matchByKey = new Map<string, TournamentMatch>(); - for (const m of matches) { - matchByKey.set(`${m.round}-${m.position}`, m); - } + const matchByKey = new Map<string, TournamentMatch>(); + for (const m of matches) { + matchByKey.set(`${m.round}-${m.position}`, m); + } - const entryBySeed = new Map<number, TournamentEntry>(); - for (const e of entries) { - entryBySeed.set(e.seed, e); - } + const entryBySeed = new Map<number, TournamentEntry>(); + for (const e of entries) { + entryBySeed.set(e.seed, e); + } - const bracketSeeds = standardBracketSeeds(bracket_size); + const bracketSeeds = standardBracketSeeds(bracket_size); - // Build rows top-to-bottom: final → ... → round 0 → players - const rows: React.ReactNode[] = []; + // Build rows top-to-bottom: final → ... → round 0 → players + const rows: React.ReactNode[] = []; - // Rounds from top (final) to bottom (round 0) - for (let round = num_rounds - 1; round >= 0; round--) { - const numPositions = bracket_size / (1 << (round + 1)); - const colSpan = bracket_size / numPositions; + // Rounds from top (final) to bottom (round 0) + for (let round = num_rounds - 1; round >= 0; round--) { + const numPositions = bracket_size / (1 << (round + 1)); + const colSpan = bracket_size / numPositions; - // Match cells for this round - const matchCells: React.ReactNode[] = []; - for (let pos = 0; pos < numPositions; pos++) { - const match = matchByKey.get(`${round}-${pos}`); - matchCells.push( - <div - key={`match-${round}-${pos}`} - style={{ - gridColumn: `${pos * colSpan + 1} / span ${colSpan}`, - }} - > - {match ? <MatchCell match={match} /> : null} - </div>, - ); - } - rows.push( - <div - key={`round-${round}`} - className="grid" - style={{ - gridTemplateColumns: `repeat(${bracket_size}, 1fr)`, - }} - > - {matchCells} - </div>, - ); + // Match cells for this round + const matchCells: React.ReactNode[] = []; + for (let pos = 0; pos < numPositions; pos++) { + const match = matchByKey.get(`${round}-${pos}`); + matchCells.push( + <div + key={`match-${round}-${pos}`} + style={{ + gridColumn: `${pos * colSpan + 1} / span ${colSpan}`, + }} + > + {match ? <MatchCell match={match} /> : null} + </div>, + ); + } + rows.push( + <div + key={`round-${round}`} + className="grid" + style={{ + gridTemplateColumns: `repeat(${bracket_size}, 1fr)`, + }} + > + {matchCells} + </div>, + ); - // Connectors below this round's matches - const connectors: React.ReactNode[] = []; - for (let pos = 0; pos < numPositions; pos++) { - const match = matchByKey.get(`${round}-${pos}`); - connectors.push( - <Connector - key={`conn-${round}-${pos}`} - position={pos} - colSpan={colSpan} - match={match} - />, - ); - } - rows.push( - <div - key={`conn-row-${round}`} - className="grid" - style={{ - gridTemplateColumns: `repeat(${bracket_size}, 1fr)`, - }} - > - {connectors} - </div>, - ); - } + // Connectors below this round's matches + const connectors: React.ReactNode[] = []; + for (let pos = 0; pos < numPositions; pos++) { + const match = matchByKey.get(`${round}-${pos}`); + connectors.push( + <Connector + key={`conn-${round}-${pos}`} + position={pos} + colSpan={colSpan} + match={match} + />, + ); + } + rows.push( + <div + key={`conn-row-${round}`} + className="grid" + style={{ + gridTemplateColumns: `repeat(${bracket_size}, 1fr)`, + }} + > + {connectors} + </div>, + ); + } - // Player cards row (bottom) - const playerCards: React.ReactNode[] = []; - for (let slot = 0; slot < bracket_size; slot++) { - const seed = bracketSeeds[slot]!; - const entry = entryBySeed.get(seed); - playerCards.push( - <div - key={`player-${slot}`} - style={{ gridColumn: `${slot + 1} / span 1` }} - > - <PlayerCard entry={entry} /> - </div>, - ); - } - rows.push( - <div - key="players" - className="grid gap-1" - style={{ gridTemplateColumns: `repeat(${bracket_size}, 1fr)` }} - > - {playerCards} - </div>, - ); + // Player cards row (bottom) + const playerCards: React.ReactNode[] = []; + for (let slot = 0; slot < bracket_size; slot++) { + const seed = bracketSeeds[slot]!; + const entry = entryBySeed.get(seed); + playerCards.push( + <div + key={`player-${slot}`} + style={{ gridColumn: `${slot + 1} / span 1` }} + > + <PlayerCard entry={entry} /> + </div>, + ); + } + rows.push( + <div + key="players" + className="grid gap-1" + style={{ gridTemplateColumns: `repeat(${bracket_size}, 1fr)` }} + > + {playerCards} + </div>, + ); - return <div className="flex flex-col gap-0">{rows}</div>; + return <div className="flex flex-col gap-0">{rows}</div>; } // Exported for testing as standardBracketSeedsForTest export { standardBracketSeeds as standardBracketSeedsForTest }; function standardBracketSeeds(bracketSize: number): number[] { - const seeds = new Array<number>(bracketSize).fill(0); - seeds[0] = 1; - for (let size = 2; size <= bracketSize; size *= 2) { - const temp = new Array<number>(size).fill(0); - for (let i = 0; i < size / 2; i++) { - temp[i * 2] = seeds[i]!; - temp[i * 2 + 1] = size + 1 - seeds[i]!; - } - for (let i = 0; i < size; i++) { - seeds[i] = temp[i]!; - } - } - return seeds; + const seeds = new Array<number>(bracketSize).fill(0); + seeds[0] = 1; + for (let size = 2; size <= bracketSize; size *= 2) { + const temp = new Array<number>(size).fill(0); + for (let i = 0; i < size / 2; i++) { + temp[i * 2] = seeds[i]!; + temp[i * 2 + 1] = size + 1 - seeds[i]!; + } + for (let i = 0; i < size; i++) { + seeds[i] = temp[i]!; + } + } + return seeds; } export default function TournamentPage({ - tournamentId, + tournamentId, }: { - tournamentId: string; + tournamentId: string; }) { - usePageTitle(`Tournament | ${APP_NAME}`); + usePageTitle(`Tournament | ${APP_NAME}`); - const id = Number(tournamentId); - const isValidId = id > 0; + const id = Number(tournamentId); + const isValidId = id > 0; - const [tournament, setTournament] = useState<Tournament | null>(null); - const [loading, setLoading] = useState(isValidId); - const [error, setError] = useState<string | null>( - isValidId ? null : "Invalid tournament ID", - ); + const [tournament, setTournament] = useState<Tournament | null>(null); + const [loading, setLoading] = useState(isValidId); + const [error, setError] = useState<string | null>( + isValidId ? null : "Invalid tournament ID", + ); - useEffect(() => { - if (!isValidId) { - return; - } + useEffect(() => { + if (!isValidId) { + return; + } - const apiClient = createApiClient(); - apiClient - .getTournament(id) - .then(({ tournament }) => setTournament(tournament)) - .catch(() => setError("Failed to load tournament")) - .finally(() => setLoading(false)); - }, [id, isValidId]); + const apiClient = createApiClient(); + apiClient + .getTournament(id) + .then(({ tournament }) => setTournament(tournament)) + .catch(() => setError("Failed to load tournament")) + .finally(() => setLoading(false)); + }, [id, isValidId]); - 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 (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> - ); - } + 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> + ); + } - return ( - <div className="p-6 bg-gray-100 min-h-screen"> - <div className="max-w-6xl mx-auto"> - <h1 className="text-3xl font-bold text-transparent bg-clip-text bg-brand-600 text-center mb-8"> - {tournament.display_name} - </h1> - <TournamentBracket tournament={tournament} /> - </div> - </div> - ); + return ( + <div className="p-6 bg-gray-100 min-h-screen"> + <div className="max-w-6xl mx-auto"> + <h1 className="text-3xl font-bold text-transparent bg-clip-text bg-brand-600 text-center mb-8"> + {tournament.display_name} + </h1> + <TournamentBracket tournament={tournament} /> + </div> + </div> + ); } |
