diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-03-08 10:51:41 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-03-08 10:51:41 +0900 |
| commit | a7ce31249948e4f0c1950de93f3c4f7cdda51cf4 (patch) | |
| tree | c4c740f0cccd15f825596f7a115f3b8f8eb8ffa7 /frontend/app/components | |
| parent | 7f4d16dca85263dcbc7b3bb29f5fc50f4371739d (diff) | |
| parent | c06d46eae30c9468535fb6af5e9b822acadbbdb6 (diff) | |
| download | phperkaigi-2025-albatross-a7ce31249948e4f0c1950de93f3c4f7cdda51cf4.tar.gz phperkaigi-2025-albatross-a7ce31249948e4f0c1950de93f3c4f7cdda51cf4.tar.zst phperkaigi-2025-albatross-a7ce31249948e4f0c1950de93f3c4f7cdda51cf4.zip | |
Merge branch 'phperkaigi-2025'
Diffstat (limited to 'frontend/app/components')
24 files changed, 477 insertions, 521 deletions
diff --git a/frontend/app/components/BorderedContainer.tsx b/frontend/app/components/BorderedContainer.tsx index cbbfbde..fe15c3b 100644 --- a/frontend/app/components/BorderedContainer.tsx +++ b/frontend/app/components/BorderedContainer.tsx @@ -6,7 +6,7 @@ type Props = { export default function BorderedContainer({ children }: Props) { return ( - <div className="bg-white border-2 border-pink-600 rounded-xl p-4"> + <div className="bg-white border-2 border-blue-600 rounded-xl p-4"> {children} </div> ); 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 4bd464b..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:8002/iosdc-japan/2024/code-battle/sock/golf/${game.game_id}/play?token=${sockToken}` - : `wss://t.nil.ninja/iosdc-japan/2024/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 0aa6b3d..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); @@ -55,7 +58,7 @@ export default function GolfPlayAppGaming({ return ( <div className="min-h-screen bg-gray-100 flex flex-col"> - <div className="text-white bg-iosdc-japan flex flex-row justify-between px-4 py-2"> + <div className="text-white bg-sky-600 flex flex-row justify-between px-4 py-2"> <div className="font-bold"> <div className="text-gray-100">{gameDisplayName}</div> <div className="text-2xl">{leftTime}</div> @@ -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/GolfPlayApps/GolfPlayAppStarting.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx index b41dfed..07b93d6 100644 --- a/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx @@ -10,7 +10,7 @@ export default function GolfPlayAppStarting({ gameDisplayName }: Props) { return ( <div className="min-h-screen bg-gray-100 flex flex-col"> - <div className="text-white bg-iosdc-japan p-10 text-center"> + <div className="text-white bg-sky-600 p-10 text-center"> <div className="text-4xl font-bold">{gameDisplayName}</div> </div> <div className="text-center text-black font-black text-10xl"> diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx index 706dc8f..4b74c0d 100644 --- a/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx @@ -12,7 +12,7 @@ export default function GolfPlayAppWaiting({ }: Props) { return ( <div className="min-h-screen bg-gray-100 flex flex-col font-bold text-center"> - <div className="text-white bg-iosdc-japan p-10"> + <div className="text-white bg-sky-600 p-10"> <div className="text-4xl">{gameDisplayName}</div> </div> <div className="grow grid mx-auto text-black"> diff --git a/frontend/app/components/GolfWatchApp.client.tsx b/frontend/app/components/GolfWatchApp.client.tsx deleted file mode 100644 index f02bfb9..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:8002/iosdc-japan/2024/code-battle/sock/golf/${game.game_id}/watch?token=${sockToken}` - : `wss://t.nil.ninja/iosdc-japan/2024/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..402884f --- /dev/null +++ b/frontend/app/components/GolfWatchApp.tsx @@ -0,0 +1,143 @@ +import { useAtom, 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, + rankingAtom, + setCurrentTimestampAtom, + setGameStartedAtAtom, + setLatestGameStatesAtom, +} from "../states/watch"; +import GolfWatchAppGaming1v1 from "./GolfWatchApps/GolfWatchAppGaming1v1"; +import GolfWatchAppGamingMultiplayer from "./GolfWatchApps/GolfWatchAppGamingMultiplayer"; +import GolfWatchAppStarting from "./GolfWatchApps/GolfWatchAppStarting"; +import GolfWatchAppWaiting1v1 from "./GolfWatchApps/GolfWatchAppWaiting1v1"; +import GolfWatchAppWaitingMultiplayer from "./GolfWatchApps/GolfWatchAppWaitingMultiplayer"; + +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 [ranking, setRanking] = useAtom(rankingAtom); + + useTimer({ delay: 1000, startImmediately: true }, setCurrentTimestamp); + + const playerA = game.main_players[0]; + const playerB = game.main_players[1]; + + const playerProfileA = playerA + ? { + id: playerA.user_id, + displayName: playerA.display_name, + iconPath: playerA.icon_path ?? null, + } + : null; + const playerProfileB = playerB + ? { + id: playerB.user_id, + displayName: playerB.display_name, + iconPath: playerB.icon_path ?? null, + } + : 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 game.game_type === "1v1" ? ( + <GolfWatchAppWaiting1v1 + gameDisplayName={game.display_name} + playerProfileA={playerProfileA!} + playerProfileB={playerProfileB!} + /> + ) : ( + <GolfWatchAppWaitingMultiplayer gameDisplayName={game.display_name} /> + ); + } else if (gameStateKind === "starting") { + return <GolfWatchAppStarting gameDisplayName={game.display_name} />; + } else if (gameStateKind === "gaming" || gameStateKind === "finished") { + return game.game_type === "1v1" ? ( + <GolfWatchAppGaming1v1 + gameDisplayName={game.display_name} + playerProfileA={playerProfileA!} + playerProfileB={playerProfileB!} + problemTitle={game.problem.title} + problemDescription={game.problem.description} + gameResult={null /* TODO */} + /> + ) : ( + <GolfWatchAppGamingMultiplayer + gameDisplayName={game.display_name} + ranking={ranking} + problemTitle={game.problem.title} + problemDescription={game.problem.description} + gameResult={null /* TODO */} + /> + ); + } else { + return null; + } +} diff --git a/frontend/app/components/GolfWatchAppWithAudioPlayRequest.client.tsx b/frontend/app/components/GolfWatchAppWithAudioPlayRequest.client.tsx deleted file mode 100644 index ce5a59c..0000000 --- a/frontend/app/components/GolfWatchAppWithAudioPlayRequest.client.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useAtom } from "jotai"; -import { AudioController } from "../.client/audio/AudioController"; -import { audioControllerAtom } from "../states/watch"; -import GolfWatchApp, { type Props } from "./GolfWatchApp.client"; -import SubmitButton from "./SubmitButton"; - -export default function GolfWatchAppWithAudioPlayRequest({ - game, - sockToken, -}: Omit<Props, "audioController">) { - const [audioController, setAudioController] = useAtom(audioControllerAtom); - const audioPlayPermitted = audioController !== null; - - if (audioPlayPermitted) { - return <GolfWatchApp game={game} sockToken={sockToken} />; - } else { - return ( - <div className="min-h-screen bg-gray-100 flex items-center justify-center"> - <div className="text-center"> - <SubmitButton - onClick={async () => { - const audioController = new AudioController(); - await audioController.loadAll(); - await audioController.playDummySoundEffect(); - setAudioController(audioController); - }} - > - 開始 - </SubmitButton> - </div> - </div> - ); - } -} 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/GolfWatchAppGaming1v1.tsx index 2907f5a..033186c 100644 --- a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming1v1.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"; @@ -24,7 +19,7 @@ type Props = { gameResult: "winA" | "winB" | "draw" | null; }; -export default function GolfWatchAppGaming({ +export default function GolfWatchAppGaming1v1({ gameDisplayName, playerProfileA, playerProfileB, @@ -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 ?? null; + const statusA = stateA?.status ?? "none"; + const stateB = latestGameStates[playerProfileB.id]; + const codeB = stateB?.code ?? ""; + const scoreB = stateB?.score ?? null; + const statusB = stateB?.status ?? "none"; const leftTime = (() => { const m = Math.floor(leftTimeSeconds / 60); @@ -52,7 +51,7 @@ export default function GolfWatchAppGaming({ : gameResult === "winB" ? "bg-purple-400" : "bg-pink-500" - : "bg-iosdc-japan"; + : "bg-sky-600"; return ( <div className="min-h-screen bg-gray-100 flex flex-col"> @@ -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/GolfWatchApps/GolfWatchAppGamingMultiplayer.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppGamingMultiplayer.tsx new file mode 100644 index 0000000..b6d2ac3 --- /dev/null +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppGamingMultiplayer.tsx @@ -0,0 +1,102 @@ +import { useAtomValue } from "jotai"; +import type { components } from "../../api/schema"; +import { gamingLeftTimeSecondsAtom } from "../../states/watch"; +import BorderedContainer from "../BorderedContainer"; + +type RankingEntry = components["schemas"]["RankingEntry"]; + +type Props = { + gameDisplayName: string; + ranking: RankingEntry[]; + problemTitle: string; + problemDescription: string; + gameResult: "winA" | "winB" | "draw" | null; +}; + +export default function GolfWatchAppGamingMultiplayer({ + gameDisplayName, + ranking, + problemTitle, + problemDescription, + gameResult, +}: Props) { + const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom)!; + + const leftTime = (() => { + const m = Math.floor(leftTimeSeconds / 60); + const s = leftTimeSeconds % 60; + return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; + })(); + + const topBg = gameResult + ? gameResult === "winA" + ? "bg-orange-400" + : gameResult === "winB" + ? "bg-purple-400" + : "bg-pink-500" + : "bg-sky-600"; + + return ( + <div className="min-h-screen bg-gray-100 flex flex-col"> + <div className={`text-white ${topBg} grid grid-cols-3 px-4 py-2`}> + <div className="font-bold flex justify-between my-auto"></div> + <div className="font-bold text-center"> + <div className="text-gray-100">{gameDisplayName}</div> + <div className="text-3xl">{leftTime}</div> + </div> + <div className="font-bold flex justify-between my-auto"></div> + </div> + <div className="grow grid grid-cols-2 p-4 gap-4"> + <div className="flex flex-col gap-4"> + <div> + <div className="mb-2 text-center text-xl font-bold"> + {problemTitle} + </div> + <BorderedContainer>{problemDescription}</BorderedContainer> + </div> + </div> + <div> + <table className="min-w-full divide-y divide-gray-200"> + <thead className="bg-gray-50"> + <tr> + <th + scope="col" + className="px-6 py-3 text-left font-medium text-gray-800 uppercase tracking-wider" + > + 順位 + </th> + <th + scope="col" + className="px-6 py-3 text-left font-medium text-gray-800 uppercase tracking-wider" + > + 名前 + </th> + <th + scope="col" + className="px-6 py-3 text-left font-medium text-gray-800 uppercase tracking-wider" + > + スコア + </th> + </tr> + </thead> + <tbody className="bg-white divide-y divide-gray-200"> + {ranking.map((entry, index) => ( + <tr key={entry.player.user_id}> + <td className="px-6 py-4 whitespace-nowrap text-gray-900"> + {index + 1} + </td> + <td className="px-6 py-4 whitespace-nowrap text-gray-900"> + {entry.player.display_name} + </td> + <td className="px-6 py-4 whitespace-nowrap text-gray-900"> + {entry.score} + </td> + </tr> + ))} + </tbody> + </table> + </div> + </div> + </div> + ); +} diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx index 684d2af..82e5334 100644 --- a/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx @@ -10,7 +10,7 @@ export default function GolfWatchAppStarting({ gameDisplayName }: Props) { return ( <div className="min-h-screen bg-gray-100 flex flex-col"> - <div className="text-white bg-iosdc-japan p-10 text-center"> + <div className="text-white bg-sky-600 p-10 text-center"> <div className="text-4xl font-bold">{gameDisplayName}</div> </div> <div className="text-center text-black font-black text-10xl"> diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting1v1.tsx index 0e964e3..fb315ff 100644 --- a/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting1v1.tsx @@ -7,14 +7,14 @@ type Props = { playerProfileB: PlayerProfile; }; -export default function GolfWatchAppWaiting({ +export default function GolfWatchAppWaiting1v1({ gameDisplayName, playerProfileA, playerProfileB, }: Props) { return ( <div className="min-h-screen bg-gray-100 flex flex-col font-bold text-center"> - <div className="text-white bg-iosdc-japan p-10"> + <div className="text-white bg-sky-600 p-10"> <div className="text-4xl">{gameDisplayName}</div> </div> <div className="grow grid grid-cols-3 gap-10 mx-auto text-black"> diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppWaitingMultiplayer.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppWaitingMultiplayer.tsx new file mode 100644 index 0000000..13bcc10 --- /dev/null +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppWaitingMultiplayer.tsx @@ -0,0 +1,15 @@ +type Props = { + gameDisplayName: string; +}; + +export default function GolfWatchAppWaitingMultiplayer({ + gameDisplayName, +}: Props) { + return ( + <div className="min-h-screen bg-gray-100 flex flex-col font-bold text-center"> + <div className="text-white bg-sky-600 p-10"> + <div className="text-4xl">{gameDisplayName}</div> + </div> + </div> + ); +} diff --git a/frontend/app/components/InputText.tsx b/frontend/app/components/InputText.tsx index 3f2c526..ed68206 100644 --- a/frontend/app/components/InputText.tsx +++ b/frontend/app/components/InputText.tsx @@ -6,7 +6,7 @@ export default function InputText(props: InputProps) { return ( <input {...props} - className="p-2 block w-full border border-pink-600 rounded-md transition duration-300 focus:ring focus:ring-pink-400 focus:outline-none" + className="p-2 block w-full border border-sky-600 rounded-md transition duration-300 focus:ring focus:ring-sky-400 focus:outline-none" /> ); } diff --git a/frontend/app/components/NavigateLink.tsx b/frontend/app/components/NavigateLink.tsx index b749cea..02aae3e 100644 --- a/frontend/app/components/NavigateLink.tsx +++ b/frontend/app/components/NavigateLink.tsx @@ -4,7 +4,7 @@ export default function NavigateLink(props: LinkProps) { return ( <Link {...props} - className="text-lg text-white bg-pink-600 px-4 py-2 rounded transition duration-300 hover:bg-pink-500 focus:ring focus:ring-pink-400 focus:outline-none" + className="text-lg text-white bg-sky-600 px-4 py-2 border-2 border-sky-50 rounded transition duration-300 hover:bg-sky-500 focus:ring focus:ring-sky-400 focus:outline-none" /> ); } diff --git a/frontend/app/components/SubmitButton.tsx b/frontend/app/components/SubmitButton.tsx index 1400a7b..643b3f5 100644 --- a/frontend/app/components/SubmitButton.tsx +++ b/frontend/app/components/SubmitButton.tsx @@ -6,7 +6,7 @@ export default function SubmitButton(props: ButtonProps) { return ( <button {...props} - className="text-lg text-white bg-pink-600 px-4 py-2 rounded transition duration-300 hover:bg-pink-500 focus:ring focus:ring-pink-400 focus:outline-none" + className="text-lg text-white bg-sky-600 px-4 py-2 rounded transition duration-300 hover:bg-sky-500 focus:ring focus:ring-sky-400 focus:outline-none" /> ); } 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": diff --git a/frontend/app/components/UserIcon.tsx b/frontend/app/components/UserIcon.tsx index 656c170..e14a571 100644 --- a/frontend/app/components/UserIcon.tsx +++ b/frontend/app/components/UserIcon.tsx @@ -9,8 +9,8 @@ export default function UserIcon({ iconPath, displayName, className }: Props) { <img src={ process.env.NODE_ENV === "development" - ? `http://localhost:8002/iosdc-japan/2024/code-battle${iconPath}` - : `/iosdc-japan/2024/code-battle${iconPath}` + ? `http://localhost:8003/phperkaigi/2025/code-battle${iconPath}` + : `/phperkaigi/2025/code-battle${iconPath}` } alt={`${displayName} のアイコン`} className={`rounded-full border-4 border-white ${className}`} |
