aboutsummaryrefslogtreecommitdiffhomepage
path: root/frontend/app/pages
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/app/pages')
-rw-r--r--frontend/app/pages/DashboardPage.tsx202
-rw-r--r--frontend/app/pages/GolfPlayPage.tsx94
-rw-r--r--frontend/app/pages/GolfProblemPreviewPage.tsx90
-rw-r--r--frontend/app/pages/GolfWatchPage.tsx116
-rw-r--r--frontend/app/pages/IndexPage.tsx62
-rw-r--r--frontend/app/pages/LoginPage.tsx166
-rw-r--r--frontend/app/pages/TournamentPage.test.tsx78
-rw-r--r--frontend/app/pages/TournamentPage.tsx510
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>
+ );
}