aboutsummaryrefslogtreecommitdiffhomepage
path: root/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'frontend')
-rw-r--r--frontend/app/App.tsx8
-rw-r--r--frontend/app/api/client.ts13
-rw-r--r--frontend/app/api/schema.d.ts75
-rw-r--r--frontend/app/pages/DashboardPage.tsx3
-rw-r--r--frontend/app/pages/SubmissionsPage.test.tsx17
-rw-r--r--frontend/app/pages/SubmissionsPage.tsx126
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",
+ });
+}