diff options
Diffstat (limited to 'frontend/app/components')
| -rw-r--r-- | frontend/app/components/Gaming/CodeBlock.tsx | 31 | ||||
| -rw-r--r-- | frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx | 11 | ||||
| -rw-r--r-- | frontend/app/components/Gaming/SubmitResult.tsx | 34 | ||||
| -rw-r--r-- | frontend/app/components/GolfPlayApp.client.tsx | 188 | ||||
| -rw-r--r-- | frontend/app/components/GolfPlayApp.tsx | 141 | ||||
| -rw-r--r-- | frontend/app/components/GolfPlayApps/GolfPlayAppConnecting.tsx | 9 | ||||
| -rw-r--r-- | frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx | 17 | ||||
| -rw-r--r-- | frontend/app/components/GolfWatchApp.client.tsx | 197 | ||||
| -rw-r--r-- | frontend/app/components/GolfWatchApp.tsx | 127 | ||||
| -rw-r--r-- | frontend/app/components/GolfWatchApps/GolfWatchAppConnecting.tsx | 9 | ||||
| -rw-r--r-- | frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx | 31 | ||||
| -rw-r--r-- | frontend/app/components/SubmitStatusLabel.tsx | 8 |
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": |
