aboutsummaryrefslogtreecommitdiffhomepage
path: root/frontend/app/components
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-03-08 10:13:05 +0900
committernsfisis <nsfisis@gmail.com>2025-03-08 10:13:05 +0900
commit8dbdf96e674c1e26d7c98af8d0608f30bc1bf166 (patch)
tree7c82476f6bbbc71d72ab7e71e39559eca197fd95 /frontend/app/components
parent54316868c3bec1ff9b04643dfe6c13cf56bf3246 (diff)
parent1e6df136d8202c8adf65948527f4c3e7583b338c (diff)
downloadphperkaigi-2025-albatross-8dbdf96e674c1e26d7c98af8d0608f30bc1bf166.tar.gz
phperkaigi-2025-albatross-8dbdf96e674c1e26d7c98af8d0608f30bc1bf166.tar.zst
phperkaigi-2025-albatross-8dbdf96e674c1e26d7c98af8d0608f30bc1bf166.zip
Merge branch 'phperkaigi-2025-ws-to-polling' into phperkaigi-2025
Diffstat (limited to 'frontend/app/components')
-rw-r--r--frontend/app/components/Gaming/CodeBlock.tsx31
-rw-r--r--frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx11
-rw-r--r--frontend/app/components/Gaming/SubmitResult.tsx34
-rw-r--r--frontend/app/components/GolfPlayApp.client.tsx188
-rw-r--r--frontend/app/components/GolfPlayApp.tsx141
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppConnecting.tsx9
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx17
-rw-r--r--frontend/app/components/GolfWatchApp.client.tsx197
-rw-r--r--frontend/app/components/GolfWatchApp.tsx127
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppConnecting.tsx9
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx31
-rw-r--r--frontend/app/components/SubmitStatusLabel.tsx8
12 files changed, 330 insertions, 473 deletions
diff --git a/frontend/app/components/Gaming/CodeBlock.tsx b/frontend/app/components/Gaming/CodeBlock.tsx
index b7d45c0..0a9a2e5 100644
--- a/frontend/app/components/Gaming/CodeBlock.tsx
+++ b/frontend/app/components/Gaming/CodeBlock.tsx
@@ -1,8 +1,5 @@
-import Prism, { highlight, languages } from "prismjs";
-import "prismjs/components/prism-swift";
-import "prismjs/themes/prism.min.css";
-
-Prism.manual = true;
+import { useEffect, useState } from "react";
+import { codeToHtml } from "shiki";
type Props = {
code: string;
@@ -10,11 +7,31 @@ type Props = {
};
export default function CodeBlock({ code, language }: Props) {
- const highlighted = highlight(code, languages[language]!, language);
+ const [highlightedCode, setHighlightedCode] = useState<string | null>(null);
+
+ useEffect(() => {
+ let isMounted = true;
+
+ (async () => {
+ const highlighted = await codeToHtml(code, {
+ lang: language,
+ theme: "github-light",
+ });
+ if (isMounted) {
+ setHighlightedCode(highlighted);
+ }
+ })();
+
+ return () => {
+ isMounted = false;
+ };
+ }, [code, language]);
return (
<pre className="h-full w-full p-2 bg-gray-50 rounded-lg border border-gray-300 whitespace-pre-wrap break-words">
- <code dangerouslySetInnerHTML={{ __html: highlighted }} />
+ {highlightedCode === null ? null : (
+ <code dangerouslySetInnerHTML={{ __html: highlightedCode }} />
+ )}
</pre>
);
}
diff --git a/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx b/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx
index a717a48..44d28ad 100644
--- a/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx
+++ b/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx
@@ -1,20 +1,19 @@
import {
- faBan,
faCircle,
faCircleCheck,
faCircleExclamation,
faRotate,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import type { ExecResultStatus } from "../../types/ExecResult";
+import type { components } from "../../api/schema";
type Props = {
- status: ExecResultStatus;
+ status: components["schemas"]["ExecutionStatus"];
};
export default function ExecStatusIndicatorIcon({ status }: Props) {
switch (status) {
- case "waiting_submission":
+ case "none":
return (
<FontAwesomeIcon icon={faCircle} fixedWidth className="text-gray-400" />
);
@@ -35,10 +34,6 @@ export default function ExecStatusIndicatorIcon({ status }: Props) {
className="text-sky-500"
/>
);
- case "canceled":
- return (
- <FontAwesomeIcon icon={faBan} fixedWidth className="text-gray-400" />
- );
default:
return (
<FontAwesomeIcon
diff --git a/frontend/app/components/Gaming/SubmitResult.tsx b/frontend/app/components/Gaming/SubmitResult.tsx
index c626910..a78c79e 100644
--- a/frontend/app/components/Gaming/SubmitResult.tsx
+++ b/frontend/app/components/Gaming/SubmitResult.tsx
@@ -1,47 +1,21 @@
import React from "react";
-import type { SubmitResult } from "../../types/SubmitResult";
-import BorderedContainer from "../BorderedContainer";
+import type { components } from "../../api/schema";
import SubmitStatusLabel from "../SubmitStatusLabel";
-import ExecStatusIndicatorIcon from "./ExecStatusIndicatorIcon";
type Props = {
- result: SubmitResult;
+ status: components["schemas"]["ExecutionStatus"];
submitButton?: React.ReactNode;
};
-export default function SubmitResult({ result, submitButton }: Props) {
+export default function SubmitResult({ status, submitButton }: Props) {
return (
<div className="flex flex-col gap-2">
<div className="flex">
{submitButton}
<div className="grow font-bold text-xl text-center">
- <SubmitStatusLabel status={result.status} />
+ <SubmitStatusLabel status={status} />
</div>
</div>
- <ul className="flex flex-col gap-4">
- {result.execResults.map((r) => (
- <li key={r.testcase_id ?? -1}>
- <BorderedContainer>
- <div className="flex flex-col gap-2">
- <div className="flex gap-2">
- <div className="my-auto">
- <ExecStatusIndicatorIcon status={r.status} />
- </div>
- <div className="font-semibold">{r.label}</div>
- </div>
- {r.stdout + r.stderr && (
- <pre className="overflow-y-hidden max-h-96 p-2 bg-gray-50 rounded-lg border border-gray-300 whitespace-pre-wrap break-words">
- <code>
- {r.stdout}
- {r.stderr}
- </code>
- </pre>
- )}
- </div>
- </BorderedContainer>
- </li>
- ))}
- </ul>
</div>
);
}
diff --git a/frontend/app/components/GolfPlayApp.client.tsx b/frontend/app/components/GolfPlayApp.client.tsx
deleted file mode 100644
index c81fe7e..0000000
--- a/frontend/app/components/GolfPlayApp.client.tsx
+++ /dev/null
@@ -1,188 +0,0 @@
-import { useAtomValue, useSetAtom } from "jotai";
-import { useEffect } from "react";
-import { useTimer } from "react-use-precision-timer";
-import { useDebouncedCallback } from "use-debounce";
-import type { components } from "../.server/api/schema";
-import useWebSocket, { ReadyState } from "../hooks/useWebSocket";
-import {
- gameStartAtom,
- gameStateKindAtom,
- handleSubmitCodeAtom,
- handleWsConnectionClosedAtom,
- handleWsExecResultMessageAtom,
- handleWsSubmitResultMessageAtom,
- setCurrentTimestampAtom,
- setGameStateConnectingAtom,
- setGameStateWaitingAtom,
-} from "../states/play";
-import GolfPlayAppConnecting from "./GolfPlayApps/GolfPlayAppConnecting";
-import GolfPlayAppFinished from "./GolfPlayApps/GolfPlayAppFinished";
-import GolfPlayAppGaming from "./GolfPlayApps/GolfPlayAppGaming";
-import GolfPlayAppStarting from "./GolfPlayApps/GolfPlayAppStarting";
-import GolfPlayAppWaiting from "./GolfPlayApps/GolfPlayAppWaiting";
-
-type GamePlayerMessageS2C = components["schemas"]["GamePlayerMessageS2C"];
-type GamePlayerMessageC2S = components["schemas"]["GamePlayerMessageC2S"];
-
-type Game = components["schemas"]["Game"];
-type User = components["schemas"]["User"];
-
-type Props = {
- game: Game;
- player: User;
- initialCode: string;
- sockToken: string;
-};
-
-export default function GolfPlayApp({
- game,
- player,
- initialCode,
- sockToken,
-}: Props) {
- const socketUrl =
- process.env.NODE_ENV === "development"
- ? `ws://localhost:8003/phperkaigi/2025/code-battle/sock/golf/${game.game_id}/play?token=${sockToken}`
- : `wss://t.nil.ninja/phperkaigi/2025/code-battle/sock/golf/${game.game_id}/play?token=${sockToken}`;
-
- const gameStateKind = useAtomValue(gameStateKindAtom);
- const setCurrentTimestamp = useSetAtom(setCurrentTimestampAtom);
- const gameStart = useSetAtom(gameStartAtom);
- const setGameStateConnecting = useSetAtom(setGameStateConnectingAtom);
- const setGameStateWaiting = useSetAtom(setGameStateWaitingAtom);
- const handleWsConnectionClosed = useSetAtom(handleWsConnectionClosedAtom);
- const handleWsExecResultMessage = useSetAtom(handleWsExecResultMessageAtom);
- const handleWsSubmitResultMessage = useSetAtom(
- handleWsSubmitResultMessageAtom,
- );
- const handleSubmitCode = useSetAtom(handleSubmitCodeAtom);
-
- useTimer({ delay: 1000, startImmediately: true }, setCurrentTimestamp);
-
- const { sendJsonMessage, lastJsonMessage, readyState } = useWebSocket<
- GamePlayerMessageS2C,
- GamePlayerMessageC2S
- >(socketUrl);
-
- const playerProfile = {
- displayName: player.display_name,
- iconPath: player.icon_path ?? null,
- };
-
- const onCodeChange = useDebouncedCallback((code: string) => {
- console.log("player:c2s:code");
- sendJsonMessage({
- type: "player:c2s:code",
- data: { code },
- });
- const baseKey = `playerState:${game.game_id}:${player.user_id}`;
- window.localStorage.setItem(`${baseKey}:code`, code);
- }, 1000);
-
- const onCodeSubmit = useDebouncedCallback((code: string) => {
- if (code === "") {
- return;
- }
- console.log("player:c2s:submit");
- sendJsonMessage({
- type: "player:c2s:submit",
- data: { code },
- });
- handleSubmitCode();
- }, 1000);
-
- if (readyState === ReadyState.UNINSTANTIATED) {
- throw new Error("WebSocket is not connected");
- }
-
- useEffect(() => {
- if (readyState === ReadyState.CLOSING || readyState === ReadyState.CLOSED) {
- handleWsConnectionClosed();
- } else if (readyState === ReadyState.CONNECTING) {
- setGameStateConnecting();
- } else if (readyState === ReadyState.OPEN) {
- if (lastJsonMessage !== null) {
- console.log(lastJsonMessage.type);
- console.log(lastJsonMessage.data);
- if (lastJsonMessage.type === "player:s2c:start") {
- const { start_at } = lastJsonMessage.data;
- gameStart(start_at);
- } else if (lastJsonMessage.type === "player:s2c:execresult") {
- handleWsExecResultMessage(
- lastJsonMessage.data,
- (submissionResult) => {
- const baseKey = `playerState:${game.game_id}:${player.user_id}`;
- window.localStorage.setItem(
- `${baseKey}:submissionResult`,
- JSON.stringify(submissionResult),
- );
- },
- );
- } else if (lastJsonMessage.type === "player:s2c:submitresult") {
- handleWsSubmitResultMessage(
- lastJsonMessage.data,
- (submissionResult, score) => {
- const baseKey = `playerState:${game.game_id}:${player.user_id}`;
- window.localStorage.setItem(
- `${baseKey}:submissionResult`,
- JSON.stringify(submissionResult),
- );
- window.localStorage.setItem(
- `${baseKey}:score`,
- score === null ? "" : score.toString(),
- );
- },
- );
- }
- } else {
- if (game.started_at) {
- gameStart(game.started_at);
- } else {
- setGameStateWaiting();
- }
- }
- }
- }, [
- game.game_id,
- game.started_at,
- player.user_id,
- sendJsonMessage,
- lastJsonMessage,
- readyState,
- gameStart,
- handleWsConnectionClosed,
- handleWsExecResultMessage,
- handleWsSubmitResultMessage,
- setGameStateConnecting,
- setGameStateWaiting,
- ]);
-
- if (gameStateKind === "connecting") {
- return <GolfPlayAppConnecting />;
- } else if (gameStateKind === "waiting") {
- return (
- <GolfPlayAppWaiting
- gameDisplayName={game.display_name}
- playerProfile={playerProfile}
- />
- );
- } else if (gameStateKind === "starting") {
- return <GolfPlayAppStarting gameDisplayName={game.display_name} />;
- } else if (gameStateKind === "gaming") {
- return (
- <GolfPlayAppGaming
- gameDisplayName={game.display_name}
- playerProfile={playerProfile}
- problemTitle={game.problem.title}
- problemDescription={game.problem.description}
- initialCode={initialCode}
- onCodeChange={onCodeChange}
- onCodeSubmit={onCodeSubmit}
- />
- );
- } else if (gameStateKind === "finished") {
- return <GolfPlayAppFinished />;
- } else {
- return null;
- }
-}
diff --git a/frontend/app/components/GolfPlayApp.tsx b/frontend/app/components/GolfPlayApp.tsx
new file mode 100644
index 0000000..e8fafbd
--- /dev/null
+++ b/frontend/app/components/GolfPlayApp.tsx
@@ -0,0 +1,141 @@
+import { useAtomValue, useSetAtom } from "jotai";
+import { useContext, useEffect, useState } from "react";
+import { useTimer } from "react-use-precision-timer";
+import { useDebouncedCallback } from "use-debounce";
+import {
+ ApiAuthTokenContext,
+ apiGetGame,
+ apiGetGamePlayLatestState,
+ apiPostGamePlayCode,
+ apiPostGamePlaySubmit,
+} from "../api/client";
+import type { components } from "../api/schema";
+import {
+ gameStateKindAtom,
+ handleSubmitCodePostAtom,
+ handleSubmitCodePreAtom,
+ setCurrentTimestampAtom,
+ setGameStartedAtAtom,
+ setLatestGameStateAtom,
+} from "../states/play";
+import GolfPlayAppFinished from "./GolfPlayApps/GolfPlayAppFinished";
+import GolfPlayAppGaming from "./GolfPlayApps/GolfPlayAppGaming";
+import GolfPlayAppStarting from "./GolfPlayApps/GolfPlayAppStarting";
+import GolfPlayAppWaiting from "./GolfPlayApps/GolfPlayAppWaiting";
+
+type Game = components["schemas"]["Game"];
+type User = components["schemas"]["User"];
+
+type Props = {
+ game: Game;
+ player: User;
+ initialCode: string;
+};
+
+export default function GolfPlayApp({ game, player, initialCode }: Props) {
+ const apiAuthToken = useContext(ApiAuthTokenContext);
+
+ const gameStateKind = useAtomValue(gameStateKindAtom);
+ const setGameStartedAt = useSetAtom(setGameStartedAtAtom);
+ const setCurrentTimestamp = useSetAtom(setCurrentTimestampAtom);
+ const handleSubmitCodePre = useSetAtom(handleSubmitCodePreAtom);
+ const handleSubmitCodePost = useSetAtom(handleSubmitCodePostAtom);
+ const setLatestGameState = useSetAtom(setLatestGameStateAtom);
+
+ useTimer({ delay: 1000, startImmediately: true }, setCurrentTimestamp);
+
+ const playerProfile = {
+ id: player.user_id,
+ displayName: player.display_name,
+ iconPath: player.icon_path ?? null,
+ };
+
+ const onCodeChange = useDebouncedCallback(async (code: string) => {
+ console.log("player:c2s:code");
+ if (game.game_type === "1v1") {
+ await apiPostGamePlayCode(apiAuthToken, game.game_id, code);
+ }
+ }, 1000);
+
+ const onCodeSubmit = useDebouncedCallback(async (code: string) => {
+ if (code === "") {
+ return;
+ }
+ console.log("player:c2s:submit");
+ handleSubmitCodePre();
+ await apiPostGamePlaySubmit(apiAuthToken, game.game_id, code);
+ handleSubmitCodePost();
+ }, 1000);
+
+ const [isDataPolling, setIsDataPolling] = useState(false);
+
+ useEffect(() => {
+ if (isDataPolling) {
+ return;
+ }
+ const timerId = setInterval(async () => {
+ if (isDataPolling) {
+ return;
+ }
+ setIsDataPolling(true);
+
+ try {
+ if (gameStateKind === "waiting") {
+ const { game: g } = await apiGetGame(apiAuthToken, game.game_id);
+ if (g.started_at != null) {
+ setGameStartedAt(g.started_at);
+ }
+ } else if (gameStateKind === "gaming") {
+ const { state } = await apiGetGamePlayLatestState(
+ apiAuthToken,
+ game.game_id,
+ );
+ setLatestGameState(state);
+ }
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setIsDataPolling(false);
+ }
+ }, 1000);
+
+ return () => {
+ clearInterval(timerId);
+ };
+ }, [
+ isDataPolling,
+ apiAuthToken,
+ game.game_id,
+ gameStateKind,
+ setGameStartedAt,
+ setLatestGameState,
+ ]);
+
+ if (gameStateKind === "waiting") {
+ return (
+ <GolfPlayAppWaiting
+ gameDisplayName={game.display_name}
+ playerProfile={playerProfile}
+ />
+ );
+ } else if (gameStateKind === "starting") {
+ return <GolfPlayAppStarting gameDisplayName={game.display_name} />;
+ } else if (gameStateKind === "gaming") {
+ return (
+ <GolfPlayAppGaming
+ gameDisplayName={game.display_name}
+ playerProfile={playerProfile}
+ problemTitle={game.problem.title}
+ problemDescription={game.problem.description}
+ sampleCode={game.problem.sample_code}
+ initialCode={initialCode}
+ onCodeChange={onCodeChange}
+ onCodeSubmit={onCodeSubmit}
+ />
+ );
+ } else if (gameStateKind === "finished") {
+ return <GolfPlayAppFinished />;
+ } else {
+ return null;
+ }
+}
diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppConnecting.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppConnecting.tsx
deleted file mode 100644
index 4b80f8f..0000000
--- a/frontend/app/components/GolfPlayApps/GolfPlayAppConnecting.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-export default function GolfPlayAppConnecting() {
- return (
- <div className="min-h-screen bg-gray-100 flex items-center justify-center">
- <div className="text-center">
- <div className="text-6xl font-bold text-black">接続中...</div>
- </div>
- </div>
- );
-}
diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx
index d4a059f..bc205fb 100644
--- a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx
+++ b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx
@@ -5,10 +5,11 @@ import SubmitButton from "../../components/SubmitButton";
import {
gamingLeftTimeSecondsAtom,
scoreAtom,
- submitResultAtom,
+ statusAtom,
} from "../../states/play";
import type { PlayerProfile } from "../../types/PlayerProfile";
import BorderedContainer from "../BorderedContainer";
+import CodeBlock from "../Gaming/CodeBlock";
import SubmitResult from "../Gaming/SubmitResult";
import UserIcon from "../UserIcon";
@@ -17,6 +18,7 @@ type Props = {
playerProfile: PlayerProfile;
problemTitle: string;
problemDescription: string;
+ sampleCode: string;
initialCode: string;
onCodeChange: (code: string) => void;
onCodeSubmit: (code: string) => void;
@@ -27,13 +29,14 @@ export default function GolfPlayAppGaming({
playerProfile,
problemTitle,
problemDescription,
+ sampleCode,
initialCode,
onCodeChange,
onCodeSubmit,
}: Props) {
const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom)!;
const score = useAtomValue(scoreAtom);
- const submitResult = useAtomValue(submitResultAtom);
+ const status = useAtomValue(statusAtom);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -80,10 +83,16 @@ export default function GolfPlayAppGaming({
<div className="grow grid grid-cols-3 divide-x divide-gray-300">
<div className="p-4">
<div className="mb-2 text-xl font-bold">{problemTitle}</div>
- <div className="p-2">
+ <div className="p-2 grid gap-4">
<BorderedContainer>
<div className="text-gray-700">{problemDescription}</div>
</BorderedContainer>
+ <BorderedContainer>
+ <div>
+ <h2>サンプルコード</h2>
+ <CodeBlock code={sampleCode} language="php" />
+ </div>
+ </BorderedContainer>
</div>
</div>
<div className="p-4">
@@ -96,7 +105,7 @@ export default function GolfPlayAppGaming({
</div>
<div className="p-4">
<SubmitResult
- result={submitResult}
+ status={status}
submitButton={
<SubmitButton onClick={handleSubmitButtonClick}>
提出
diff --git a/frontend/app/components/GolfWatchApp.client.tsx b/frontend/app/components/GolfWatchApp.client.tsx
deleted file mode 100644
index e80a009..0000000
--- a/frontend/app/components/GolfWatchApp.client.tsx
+++ /dev/null
@@ -1,197 +0,0 @@
-import { useAtomValue, useSetAtom } from "jotai";
-import { useCallback, useEffect } from "react";
-import { useTimer } from "react-use-precision-timer";
-import type { components } from "../.server/api/schema";
-import useWebSocket, { ReadyState } from "../hooks/useWebSocket";
-import {
- gameStartAtom,
- gameStateKindAtom,
- handleWsCodeMessageAtom,
- handleWsConnectionClosedAtom,
- handleWsExecResultMessageAtom,
- handleWsSubmitMessageAtom,
- handleWsSubmitResultMessageAtom,
- setCurrentTimestampAtom,
- setGameStateConnectingAtom,
- setGameStateWaitingAtom,
-} from "../states/watch";
-import GolfWatchAppConnecting from "./GolfWatchApps/GolfWatchAppConnecting";
-import GolfWatchAppGaming from "./GolfWatchApps/GolfWatchAppGaming";
-import GolfWatchAppStarting from "./GolfWatchApps/GolfWatchAppStarting";
-import GolfWatchAppWaiting from "./GolfWatchApps/GolfWatchAppWaiting";
-
-type GameWatcherMessageS2C = components["schemas"]["GameWatcherMessageS2C"];
-type GameWatcherMessageC2S = never;
-
-type Game = components["schemas"]["Game"];
-
-export type Props = {
- game: Game;
- sockToken: string;
-};
-
-export default function GolfWatchApp({ game, sockToken }: Props) {
- const socketUrl =
- process.env.NODE_ENV === "development"
- ? `ws://localhost:8003/phperkaigi/2025/code-battle/sock/golf/${game.game_id}/watch?token=${sockToken}`
- : `wss://t.nil.ninja/phperkaigi/2025/code-battle/sock/golf/${game.game_id}/watch?token=${sockToken}`;
-
- const gameStateKind = useAtomValue(gameStateKindAtom);
- const setCurrentTimestamp = useSetAtom(setCurrentTimestampAtom);
- const gameStart = useSetAtom(gameStartAtom);
- const setGameStateConnecting = useSetAtom(setGameStateConnectingAtom);
- const setGameStateWaiting = useSetAtom(setGameStateWaitingAtom);
- const handleWsConnectionClosed = useSetAtom(handleWsConnectionClosedAtom);
- const handleWsCodeMessage = useSetAtom(handleWsCodeMessageAtom);
- const handleWsSubmitMessage = useSetAtom(handleWsSubmitMessageAtom);
- const handleWsExecResultMessage = useSetAtom(handleWsExecResultMessageAtom);
- const handleWsSubmitResultMessage = useSetAtom(
- handleWsSubmitResultMessageAtom,
- );
-
- useTimer({ delay: 1000, startImmediately: true }, setCurrentTimestamp);
-
- const { lastJsonMessage, readyState } = useWebSocket<
- GameWatcherMessageS2C,
- GameWatcherMessageC2S
- >(socketUrl);
-
- const playerA = game.players[0]!;
- const playerB = game.players[1]!;
-
- const getTargetAtomByPlayerId: <T>(
- player_id: number,
- atomA: T,
- atomB: T,
- ) => T = useCallback(
- (player_id, atomA, atomB) =>
- player_id === playerA.user_id ? atomA : atomB,
- [playerA.user_id],
- );
-
- const playerProfileA = {
- displayName: playerA.display_name,
- iconPath: playerA.icon_path ?? null,
- };
- const playerProfileB = {
- displayName: playerB.display_name,
- iconPath: playerB.icon_path ?? null,
- };
-
- if (readyState === ReadyState.UNINSTANTIATED) {
- throw new Error("WebSocket is not connected");
- }
-
- useEffect(() => {
- if (readyState === ReadyState.CLOSING || readyState === ReadyState.CLOSED) {
- handleWsConnectionClosed();
- } else if (readyState === ReadyState.CONNECTING) {
- setGameStateConnecting();
- } else if (readyState === ReadyState.OPEN) {
- if (lastJsonMessage !== null) {
- console.log(lastJsonMessage.type);
- console.log(lastJsonMessage.data);
- if (lastJsonMessage.type === "watcher:s2c:start") {
- const { start_at } = lastJsonMessage.data;
- gameStart(start_at);
- } else if (lastJsonMessage.type === "watcher:s2c:code") {
- handleWsCodeMessage(
- lastJsonMessage.data,
- getTargetAtomByPlayerId,
- (player_id, code) => {
- const baseKey = `watcherState:${game.game_id}:${player_id}`;
- window.localStorage.setItem(`${baseKey}:code`, code);
- },
- );
- } else if (lastJsonMessage.type === "watcher:s2c:submit") {
- handleWsSubmitMessage(
- lastJsonMessage.data,
- getTargetAtomByPlayerId,
- (player_id, submissionResult) => {
- const baseKey = `watcherState:${game.game_id}:${player_id}`;
- window.localStorage.setItem(
- `${baseKey}:submissionResult`,
- JSON.stringify(submissionResult),
- );
- },
- );
- } else if (lastJsonMessage.type === "watcher:s2c:execresult") {
- handleWsExecResultMessage(
- lastJsonMessage.data,
- getTargetAtomByPlayerId,
- (player_id, submissionResult) => {
- const baseKey = `watcherState:${game.game_id}:${player_id}`;
- window.localStorage.setItem(
- `${baseKey}:submissionResult`,
- JSON.stringify(submissionResult),
- );
- },
- );
- } else if (lastJsonMessage.type === "watcher:s2c:submitresult") {
- handleWsSubmitResultMessage(
- lastJsonMessage.data,
- getTargetAtomByPlayerId,
- (player_id, submissionResult, score) => {
- const baseKey = `watcherState:${game.game_id}:${player_id}`;
- window.localStorage.setItem(
- `${baseKey}:submissionResult`,
- JSON.stringify(submissionResult),
- );
- window.localStorage.setItem(
- `${baseKey}:score`,
- score === null ? "" : score.toString(),
- );
- },
- );
- }
- } else {
- if (game.started_at) {
- gameStart(game.started_at);
- } else {
- setGameStateWaiting();
- }
- }
- }
- }, [
- game.started_at,
- game.game_id,
- lastJsonMessage,
- readyState,
- gameStart,
- getTargetAtomByPlayerId,
- handleWsCodeMessage,
- handleWsConnectionClosed,
- handleWsExecResultMessage,
- handleWsSubmitMessage,
- handleWsSubmitResultMessage,
- setGameStateConnecting,
- setGameStateWaiting,
- ]);
-
- if (gameStateKind === "connecting") {
- return <GolfWatchAppConnecting />;
- } else if (gameStateKind === "waiting") {
- return (
- <GolfWatchAppWaiting
- gameDisplayName={game.display_name}
- playerProfileA={playerProfileA}
- playerProfileB={playerProfileB}
- />
- );
- } else if (gameStateKind === "starting") {
- return <GolfWatchAppStarting gameDisplayName={game.display_name} />;
- } else if (gameStateKind === "gaming" || gameStateKind === "finished") {
- return (
- <GolfWatchAppGaming
- gameDisplayName={game.display_name}
- playerProfileA={playerProfileA}
- playerProfileB={playerProfileB}
- problemTitle={game.problem.title}
- problemDescription={game.problem.description}
- gameResult={null /* TODO */}
- />
- );
- } else {
- return null;
- }
-}
diff --git a/frontend/app/components/GolfWatchApp.tsx b/frontend/app/components/GolfWatchApp.tsx
new file mode 100644
index 0000000..fe71932
--- /dev/null
+++ b/frontend/app/components/GolfWatchApp.tsx
@@ -0,0 +1,127 @@
+import { useAtomValue, useSetAtom } from "jotai";
+import { useContext, useEffect, useState } from "react";
+import { useTimer } from "react-use-precision-timer";
+import {
+ ApiAuthTokenContext,
+ apiGetGame,
+ apiGetGameWatchLatestStates,
+ apiGetGameWatchRanking,
+} from "../api/client";
+import type { components } from "../api/schema";
+import {
+ gameStateKindAtom,
+ setCurrentTimestampAtom,
+ setGameStartedAtAtom,
+ setLatestGameStatesAtom,
+ setRankingAtom,
+} from "../states/watch";
+import GolfWatchAppGaming from "./GolfWatchApps/GolfWatchAppGaming";
+import GolfWatchAppStarting from "./GolfWatchApps/GolfWatchAppStarting";
+import GolfWatchAppWaiting from "./GolfWatchApps/GolfWatchAppWaiting";
+
+type Game = components["schemas"]["Game"];
+
+export type Props = {
+ game: Game;
+};
+
+export default function GolfWatchApp({ game }: Props) {
+ const apiAuthToken = useContext(ApiAuthTokenContext);
+
+ const gameStateKind = useAtomValue(gameStateKindAtom);
+ const setGameStartedAt = useSetAtom(setGameStartedAtAtom);
+ const setCurrentTimestamp = useSetAtom(setCurrentTimestampAtom);
+ const setLatestGameStates = useSetAtom(setLatestGameStatesAtom);
+ const setRanking = useSetAtom(setRankingAtom);
+
+ useTimer({ delay: 1000, startImmediately: true }, setCurrentTimestamp);
+
+ const playerA = game.main_players[0]!;
+ const playerB = game.main_players[1]!;
+
+ const playerProfileA = {
+ id: playerA.user_id,
+ displayName: playerA.display_name,
+ iconPath: playerA.icon_path ?? null,
+ };
+ const playerProfileB = {
+ id: playerB.user_id,
+ displayName: playerB.display_name,
+ iconPath: playerB.icon_path ?? null,
+ };
+
+ const [isDataPolling, setIsDataPolling] = useState(false);
+
+ useEffect(() => {
+ if (isDataPolling) {
+ return;
+ }
+ const timerId = setInterval(async () => {
+ if (isDataPolling) {
+ return;
+ }
+ setIsDataPolling(true);
+
+ try {
+ if (gameStateKind === "waiting") {
+ const { game: g } = await apiGetGame(apiAuthToken, game.game_id);
+ if (g.started_at != null) {
+ setGameStartedAt(g.started_at);
+ }
+ } else if (gameStateKind === "gaming") {
+ const { states } = await apiGetGameWatchLatestStates(
+ apiAuthToken,
+ game.game_id,
+ );
+ setLatestGameStates(states);
+ const { ranking } = await apiGetGameWatchRanking(
+ apiAuthToken,
+ game.game_id,
+ );
+ setRanking(ranking);
+ }
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setIsDataPolling(false);
+ }
+ }, 1000);
+
+ return () => {
+ clearInterval(timerId);
+ };
+ }, [
+ isDataPolling,
+ apiAuthToken,
+ game.game_id,
+ gameStateKind,
+ setGameStartedAt,
+ setLatestGameStates,
+ setRanking,
+ ]);
+
+ if (gameStateKind === "waiting") {
+ return (
+ <GolfWatchAppWaiting
+ gameDisplayName={game.display_name}
+ playerProfileA={playerProfileA}
+ playerProfileB={playerProfileB}
+ />
+ );
+ } else if (gameStateKind === "starting") {
+ return <GolfWatchAppStarting gameDisplayName={game.display_name} />;
+ } else if (gameStateKind === "gaming" || gameStateKind === "finished") {
+ return (
+ <GolfWatchAppGaming
+ gameDisplayName={game.display_name}
+ playerProfileA={playerProfileA}
+ playerProfileB={playerProfileB}
+ problemTitle={game.problem.title}
+ problemDescription={game.problem.description}
+ gameResult={null /* TODO */}
+ />
+ );
+ } else {
+ return null;
+ }
+}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppConnecting.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppConnecting.tsx
deleted file mode 100644
index 07a1be8..0000000
--- a/frontend/app/components/GolfWatchApps/GolfWatchAppConnecting.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-export default function GolfWatchAppConnecting() {
- return (
- <div className="min-h-screen bg-gray-100 flex items-center justify-center">
- <div className="text-center">
- <div className="text-6xl font-bold text-black">接続中...</div>
- </div>
- </div>
- );
-}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
index 7cfbc86..afb8bfe 100644
--- a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
+++ b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
@@ -1,12 +1,7 @@
import { useAtomValue } from "jotai";
import {
- codeAAtom,
- codeBAtom,
gamingLeftTimeSecondsAtom,
- scoreAAtom,
- scoreBAtom,
- submitResultAAtom,
- submitResultBAtom,
+ latestGameStatesAtom,
} from "../../states/watch";
import type { PlayerProfile } from "../../types/PlayerProfile";
import BorderedContainer from "../BorderedContainer";
@@ -33,12 +28,16 @@ export default function GolfWatchAppGaming({
gameResult,
}: Props) {
const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom)!;
- const codeA = useAtomValue(codeAAtom);
- const codeB = useAtomValue(codeBAtom);
- const scoreA = useAtomValue(scoreAAtom);
- const scoreB = useAtomValue(scoreBAtom);
- const submitResultA = useAtomValue(submitResultAAtom);
- const submitResultB = useAtomValue(submitResultBAtom);
+ const latestGameStates = useAtomValue(latestGameStatesAtom);
+
+ const stateA = latestGameStates[playerProfileA.id]!;
+ const codeA = stateA.code;
+ const scoreA = stateA.score;
+ const statusA = stateA.status;
+ const stateB = latestGameStates[playerProfileB.id]!;
+ const codeB = stateB.code;
+ const scoreB = stateB.score;
+ const statusB = stateB.status;
const leftTime = (() => {
const m = Math.floor(leftTimeSeconds / 60);
@@ -109,11 +108,11 @@ export default function GolfWatchAppGaming({
bgB="bg-purple-400"
/>
<div className="grow grid grid-cols-3 p-4 gap-4">
- <CodeBlock code={codeA} language="swift" />
+ <CodeBlock code={codeA} language="php" />
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-4">
- <SubmitResult result={submitResultA} />
- <SubmitResult result={submitResultB} />
+ <SubmitResult status={statusA} />
+ <SubmitResult status={statusB} />
</div>
<div>
<div className="mb-2 text-center text-xl font-bold">
@@ -122,7 +121,7 @@ export default function GolfWatchAppGaming({
<BorderedContainer>{problemDescription}</BorderedContainer>
</div>
</div>
- <CodeBlock code={codeB} language="swift" />
+ <CodeBlock code={codeB} language="php" />
</div>
</div>
);
diff --git a/frontend/app/components/SubmitStatusLabel.tsx b/frontend/app/components/SubmitStatusLabel.tsx
index d1dc89c..8384e95 100644
--- a/frontend/app/components/SubmitStatusLabel.tsx
+++ b/frontend/app/components/SubmitStatusLabel.tsx
@@ -1,12 +1,12 @@
-import type { SubmitResultStatus } from "../types/SubmitResult";
+import type { components } from "../api/schema";
type Props = {
- status: SubmitResultStatus;
+ status: components["schemas"]["ExecutionStatus"];
};
export default function SubmitStatusLabel({ status }: Props) {
switch (status) {
- case "waiting_submission":
+ case "none":
return "提出待ち";
case "running":
return "実行中...";
@@ -16,8 +16,6 @@ export default function SubmitStatusLabel({ status }: Props) {
return "テスト失敗";
case "timeout":
return "時間切れ";
- case "compile_error":
- return "コンパイルエラー";
case "runtime_error":
return "実行時エラー";
case "internal_error":