diff options
Diffstat (limited to 'frontend')
| -rw-r--r-- | frontend/app/App.tsx | 8 | ||||
| -rw-r--r-- | frontend/app/api/client.ts | 13 | ||||
| -rw-r--r-- | frontend/app/api/schema.d.ts | 75 | ||||
| -rw-r--r-- | frontend/app/pages/DashboardPage.tsx | 3 | ||||
| -rw-r--r-- | frontend/app/pages/SubmissionsPage.test.tsx | 17 | ||||
| -rw-r--r-- | frontend/app/pages/SubmissionsPage.tsx | 126 |
6 files changed, 242 insertions, 0 deletions
diff --git a/frontend/app/App.tsx b/frontend/app/App.tsx index 651de32..9ac8007 100644 --- a/frontend/app/App.tsx +++ b/frontend/app/App.tsx @@ -7,6 +7,7 @@ import GolfPlayPage from "./pages/GolfPlayPage"; import GolfWatchPage from "./pages/GolfWatchPage"; import IndexPage from "./pages/IndexPage"; import LoginPage from "./pages/LoginPage"; +import SubmissionsPage from "./pages/SubmissionsPage"; import TournamentPage from "./pages/TournamentPage"; export default function App() { @@ -35,6 +36,13 @@ export default function App() { </ProtectedRoute> )} </Route> + <Route path="/golf/:gameId/submissions"> + {(params) => ( + <ProtectedRoute> + <SubmissionsPage gameId={params.gameId} /> + </ProtectedRoute> + )} + </Route> <Route path="/golf/:gameId/watch"> {(params) => ( <ProtectedRoute> diff --git a/frontend/app/api/client.ts b/frontend/app/api/client.ts index c9647ba..db5f8c8 100644 --- a/frontend/app/api/client.ts +++ b/frontend/app/api/client.ts @@ -85,6 +85,19 @@ class AuthenticatedApiClient { return data; } + async getGamePlaySubmissions(gameId: number) { + const { data, error } = await client.GET( + "/games/{game_id}/play/submissions", + { + params: { + path: { game_id: gameId }, + }, + }, + ); + if (error) throw new Error(error.message); + return data; + } + async getGameWatchRanking(gameId: number) { const { data, error } = await client.GET("/games/{game_id}/watch/ranking", { params: { diff --git a/frontend/app/api/schema.d.ts b/frontend/app/api/schema.d.ts index b891bfa..e720f52 100644 --- a/frontend/app/api/schema.d.ts +++ b/frontend/app/api/schema.d.ts @@ -68,6 +68,22 @@ export interface paths { patch?: never; trace?: never; }; + "/games/{game_id}/play/submissions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getGamePlaySubmissions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/games/{game_id}/play/submit": { parameters: { query?: never; @@ -222,6 +238,14 @@ export interface components { submitted_at: number; code: string | null; }; + Submission: { + submission_id: number; + game_id: number; + code: string; + code_size: number; + status: components["schemas"]["ExecutionStatus"]; + created_at: number; + }; Tournament: { tournament_id: number; display_name: string; @@ -458,6 +482,57 @@ export interface operations { }; }; }; + getGamePlaySubmissions: { + parameters: { + query?: never; + header?: never; + path: { + game_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The request has succeeded. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + submissions: components["schemas"]["Submission"][]; + }; + }; + }; + /** @description Access is unauthorized. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Access is forbidden. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The server cannot find the requested resource. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; postGamePlaySubmit: { parameters: { query?: never; diff --git a/frontend/app/pages/DashboardPage.tsx b/frontend/app/pages/DashboardPage.tsx index 708a867..3191f1b 100644 --- a/frontend/app/pages/DashboardPage.tsx +++ b/frontend/app/pages/DashboardPage.tsx @@ -74,6 +74,9 @@ export default function DashboardPage() { <NavigateLink to={`/golf/${game.game_id}/watch`}> 観戦 </NavigateLink> + <NavigateLink to={`/golf/${game.game_id}/submissions`}> + 提出履歴 + </NavigateLink> </div> </li> ))} diff --git a/frontend/app/pages/SubmissionsPage.test.tsx b/frontend/app/pages/SubmissionsPage.test.tsx new file mode 100644 index 0000000..f01f4c9 --- /dev/null +++ b/frontend/app/pages/SubmissionsPage.test.tsx @@ -0,0 +1,17 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import SubmissionsPage from "./SubmissionsPage"; + +afterEach(() => { + cleanup(); +}); + +describe("SubmissionsPage", () => { + test("shows loading state initially", () => { + render(<SubmissionsPage gameId="1" />); + expect(screen.getByText("Loading...")).toBeDefined(); + }); +}); diff --git a/frontend/app/pages/SubmissionsPage.tsx b/frontend/app/pages/SubmissionsPage.tsx new file mode 100644 index 0000000..2c3329d --- /dev/null +++ b/frontend/app/pages/SubmissionsPage.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from "react"; +import { createApiClient } from "../api/client"; +import type { components } from "../api/schema"; +import BorderedContainerWithCaption from "../components/BorderedContainerWithCaption"; +import NavigateLink from "../components/NavigateLink"; +import SubmitStatusLabel from "../components/SubmitStatusLabel"; +import { APP_NAME } from "../config"; +import { usePageTitle } from "../hooks/usePageTitle"; + +type Submission = components["schemas"]["Submission"]; + +export default function SubmissionsPage({ gameId }: { gameId: string }) { + usePageTitle(`Submissions | ${APP_NAME}`); + + const [submissions, setSubmissions] = useState<Submission[]>([]); + const [loading, setLoading] = useState(true); + const [expandedId, setExpandedId] = useState<number | null>(null); + + const numericGameId = Number(gameId); + + useEffect(() => { + const apiClient = createApiClient(); + apiClient + .getGamePlaySubmissions(numericGameId) + .then(({ submissions }) => setSubmissions(submissions)) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [numericGameId]); + + if (loading) { + return ( + <div className="min-h-screen bg-gray-100 flex items-center justify-center"> + <p className="text-gray-500">Loading...</p> + </div> + ); + } + + return ( + <div className="p-6 bg-gray-100 min-h-screen flex flex-col items-center gap-4"> + <BorderedContainerWithCaption caption="提出履歴"> + <div className="px-4"> + {submissions.length === 0 ? ( + <p>提出履歴はありません</p> + ) : ( + <ul className="divide-y divide-gray-300"> + {submissions.map((s) => ( + <li key={s.submission_id} className="py-3"> + <div className="flex justify-between items-center gap-4"> + <div className="flex items-center gap-3"> + <StatusBadge status={s.status} /> + <span className="font-mono text-lg font-bold"> + {s.code_size} + <span className="text-sm font-normal text-gray-500 ml-1"> + bytes + </span> + </span> + </div> + <div className="flex items-center gap-3"> + <span className="text-sm text-gray-500"> + {formatDate(s.created_at)} + </span> + <button + type="button" + onClick={() => + setExpandedId( + expandedId === s.submission_id + ? null + : s.submission_id, + ) + } + className="text-sm text-sky-600 hover:text-sky-800 underline" + > + {expandedId === s.submission_id + ? "コードを隠す" + : "コードを見る"} + </button> + </div> + </div> + {expandedId === s.submission_id && ( + <pre className="mt-2 p-3 bg-gray-800 text-gray-100 rounded text-sm overflow-x-auto"> + {s.code} + </pre> + )} + </li> + ))} + </ul> + )} + </div> + </BorderedContainerWithCaption> + <NavigateLink to={`/golf/${gameId}/play`}>対戦に戻る</NavigateLink> + <NavigateLink to="/dashboard">ダッシュボードに戻る</NavigateLink> + </div> + ); +} + +function StatusBadge({ + status, +}: { + status: components["schemas"]["ExecutionStatus"]; +}) { + const colorClass = + status === "success" + ? "bg-green-100 text-green-800" + : status === "running" + ? "bg-yellow-100 text-yellow-800" + : status === "none" + ? "bg-gray-100 text-gray-800" + : "bg-red-100 text-red-800"; + + return ( + <span className={`px-2 py-1 rounded text-sm font-medium ${colorClass}`}> + <SubmitStatusLabel status={status} /> + </span> + ); +} + +function formatDate(unixTimestamp: number): string { + const date = new Date(unixTimestamp * 1000); + return date.toLocaleString("ja-JP", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} |
