aboutsummaryrefslogtreecommitdiffhomepage
path: root/frontend
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-20 23:32:22 +0900
committernsfisis <nsfisis@gmail.com>2026-02-20 23:32:22 +0900
commit8e73d12a703e90ad908962143951178c13d0d6fe (patch)
tree8bed43aa4b115f8bc50ed258aa192a94b6d2903e /frontend
parentaa07ba2e0a40b0097a4f9aee3c06dcbd9a749105 (diff)
downloadphperkaigi-2026-albatross-8e73d12a703e90ad908962143951178c13d0d6fe.tar.gz
phperkaigi-2026-albatross-8e73d12a703e90ad908962143951178c13d0d6fe.tar.zst
phperkaigi-2026-albatross-8e73d12a703e90ad908962143951178c13d0d6fe.zip
feat: add user submission history page
Allow users to view their own past submissions (code, size, status, timestamp) for each game. Adds API endpoint, backend handler, SQL query, and frontend page with expandable code display. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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",
+ });
+}