diff options
Diffstat (limited to 'frontend/app/components')
48 files changed, 1568 insertions, 1568 deletions
diff --git a/frontend/app/components/BorderedContainer.test.tsx b/frontend/app/components/BorderedContainer.test.tsx index 22a0aec..2f60499 100644 --- a/frontend/app/components/BorderedContainer.test.tsx +++ b/frontend/app/components/BorderedContainer.test.tsx @@ -6,28 +6,28 @@ import { afterEach, describe, expect, test } from "vitest"; import BorderedContainer from "./BorderedContainer"; afterEach(() => { - cleanup(); + cleanup(); }); describe("BorderedContainer", () => { - test("renders children", () => { - render(<BorderedContainer>Hello World</BorderedContainer>); - expect(screen.getByText("Hello World")).toBeDefined(); - }); + test("renders children", () => { + render(<BorderedContainer>Hello World</BorderedContainer>); + expect(screen.getByText("Hello World")).toBeDefined(); + }); - test("applies custom className", () => { - render( - <BorderedContainer className="custom-class">Content</BorderedContainer>, - ); - const container = screen.getByText("Content").closest("div"); - expect(container?.className).toContain("custom-class"); - }); + test("applies custom className", () => { + render( + <BorderedContainer className="custom-class">Content</BorderedContainer>, + ); + const container = screen.getByText("Content").closest("div"); + expect(container?.className).toContain("custom-class"); + }); - test("has default border styling", () => { - render(<BorderedContainer>Styled</BorderedContainer>); - const container = screen.getByText("Styled").closest("div"); - expect(container?.className).toContain("border-2"); - expect(container?.className).toContain("border-brand-600"); - expect(container?.className).toContain("rounded-xl"); - }); + test("has default border styling", () => { + render(<BorderedContainer>Styled</BorderedContainer>); + const container = screen.getByText("Styled").closest("div"); + expect(container?.className).toContain("border-2"); + expect(container?.className).toContain("border-brand-600"); + expect(container?.className).toContain("rounded-xl"); + }); }); diff --git a/frontend/app/components/BorderedContainer.tsx b/frontend/app/components/BorderedContainer.tsx index a1bff35..ec6c00e 100644 --- a/frontend/app/components/BorderedContainer.tsx +++ b/frontend/app/components/BorderedContainer.tsx @@ -1,16 +1,16 @@ import React from "react"; type Props = { - children: React.ReactNode; - className?: string; + children: React.ReactNode; + className?: string; }; export default function BorderedContainer({ children, className }: Props) { - return ( - <div - className={`bg-white border-2 border-brand-600 rounded-xl p-4 ${className}`} - > - {children} - </div> - ); + return ( + <div + className={`bg-white border-2 border-brand-600 rounded-xl p-4 ${className}`} + > + {children} + </div> + ); } diff --git a/frontend/app/components/BorderedContainerWithCaption.test.tsx b/frontend/app/components/BorderedContainerWithCaption.test.tsx index 621dcb3..da6246b 100644 --- a/frontend/app/components/BorderedContainerWithCaption.test.tsx +++ b/frontend/app/components/BorderedContainerWithCaption.test.tsx @@ -6,36 +6,36 @@ import { afterEach, describe, expect, test } from "vitest"; import BorderedContainerWithCaption from "./BorderedContainerWithCaption"; afterEach(() => { - cleanup(); + cleanup(); }); describe("BorderedContainerWithCaption", () => { - test("renders caption as heading", () => { - render( - <BorderedContainerWithCaption caption="Test Caption"> - Content - </BorderedContainerWithCaption>, - ); - expect(screen.getByText("Test Caption")).toBeDefined(); - expect(screen.getByText("Test Caption").tagName).toBe("H2"); - }); + test("renders caption as heading", () => { + render( + <BorderedContainerWithCaption caption="Test Caption"> + Content + </BorderedContainerWithCaption>, + ); + expect(screen.getByText("Test Caption")).toBeDefined(); + expect(screen.getByText("Test Caption").tagName).toBe("H2"); + }); - test("renders children", () => { - render( - <BorderedContainerWithCaption caption="Title"> - Child Content - </BorderedContainerWithCaption>, - ); - expect(screen.getByText("Child Content")).toBeDefined(); - }); + test("renders children", () => { + render( + <BorderedContainerWithCaption caption="Title"> + Child Content + </BorderedContainerWithCaption>, + ); + expect(screen.getByText("Child Content")).toBeDefined(); + }); - test("wraps in bordered container with blue border", () => { - render( - <BorderedContainerWithCaption caption="Title"> - Content - </BorderedContainerWithCaption>, - ); - const container = screen.getByText("Content").closest(".border-2"); - expect(container).not.toBeNull(); - }); + test("wraps in bordered container with blue border", () => { + render( + <BorderedContainerWithCaption caption="Title"> + Content + </BorderedContainerWithCaption>, + ); + const container = screen.getByText("Content").closest(".border-2"); + expect(container).not.toBeNull(); + }); }); diff --git a/frontend/app/components/BorderedContainerWithCaption.tsx b/frontend/app/components/BorderedContainerWithCaption.tsx index 5446ddc..4cee1c9 100644 --- a/frontend/app/components/BorderedContainerWithCaption.tsx +++ b/frontend/app/components/BorderedContainerWithCaption.tsx @@ -2,20 +2,20 @@ import React from "react"; import BorderedContainer from "./BorderedContainer"; type Props = { - caption: string; - children: React.ReactNode; + caption: string; + children: React.ReactNode; }; export default function BorderedContainerWithCaption({ - caption, - children, + caption, + children, }: Props) { - return ( - <BorderedContainer> - <div className="flex flex-col gap-4"> - <h2 className="text-center text-lg font-semibold">{caption}</h2> - {children} - </div> - </BorderedContainer> - ); + return ( + <BorderedContainer> + <div className="flex flex-col gap-4"> + <h2 className="text-center text-lg font-semibold">{caption}</h2> + {children} + </div> + </BorderedContainer> + ); } diff --git a/frontend/app/components/FoldableBorderedContainerWithCaption.test.tsx b/frontend/app/components/FoldableBorderedContainerWithCaption.test.tsx index a4434ff..bfbb496 100644 --- a/frontend/app/components/FoldableBorderedContainerWithCaption.test.tsx +++ b/frontend/app/components/FoldableBorderedContainerWithCaption.test.tsx @@ -6,51 +6,51 @@ import { afterEach, describe, expect, test } from "vitest"; import FoldableBorderedContainerWithCaption from "./FoldableBorderedContainerWithCaption"; afterEach(() => { - cleanup(); + cleanup(); }); describe("FoldableBorderedContainerWithCaption", () => { - test("renders caption", () => { - render( - <FoldableBorderedContainerWithCaption caption="Foldable Title"> - Content - </FoldableBorderedContainerWithCaption>, - ); - expect(screen.getByText("Foldable Title")).toBeDefined(); - }); + test("renders caption", () => { + render( + <FoldableBorderedContainerWithCaption caption="Foldable Title"> + Content + </FoldableBorderedContainerWithCaption>, + ); + expect(screen.getByText("Foldable Title")).toBeDefined(); + }); - test("shows children by default (open state)", () => { - render( - <FoldableBorderedContainerWithCaption caption="Title"> - <div data-testid="child">Visible</div> - </FoldableBorderedContainerWithCaption>, - ); - const child = screen.getByTestId("child"); - expect(child.parentElement?.className).not.toContain("hidden"); - }); + test("shows children by default (open state)", () => { + render( + <FoldableBorderedContainerWithCaption caption="Title"> + <div data-testid="child">Visible</div> + </FoldableBorderedContainerWithCaption>, + ); + const child = screen.getByTestId("child"); + expect(child.parentElement?.className).not.toContain("hidden"); + }); - test("hides children when toggle button is clicked", () => { - render( - <FoldableBorderedContainerWithCaption caption="Title"> - <div data-testid="child">Content</div> - </FoldableBorderedContainerWithCaption>, - ); - const toggleButton = screen.getByRole("button"); - fireEvent.click(toggleButton); - const child = screen.getByTestId("child"); - expect(child.parentElement?.className).toContain("hidden"); - }); + test("hides children when toggle button is clicked", () => { + render( + <FoldableBorderedContainerWithCaption caption="Title"> + <div data-testid="child">Content</div> + </FoldableBorderedContainerWithCaption>, + ); + const toggleButton = screen.getByRole("button"); + fireEvent.click(toggleButton); + const child = screen.getByTestId("child"); + expect(child.parentElement?.className).toContain("hidden"); + }); - test("shows children again when toggle button is clicked twice", () => { - render( - <FoldableBorderedContainerWithCaption caption="Title"> - <div data-testid="child">Content</div> - </FoldableBorderedContainerWithCaption>, - ); - const toggleButton = screen.getByRole("button"); - fireEvent.click(toggleButton); - fireEvent.click(toggleButton); - const child = screen.getByTestId("child"); - expect(child.parentElement?.className).not.toContain("hidden"); - }); + test("shows children again when toggle button is clicked twice", () => { + render( + <FoldableBorderedContainerWithCaption caption="Title"> + <div data-testid="child">Content</div> + </FoldableBorderedContainerWithCaption>, + ); + const toggleButton = screen.getByRole("button"); + fireEvent.click(toggleButton); + fireEvent.click(toggleButton); + const child = screen.getByTestId("child"); + expect(child.parentElement?.className).not.toContain("hidden"); + }); }); diff --git a/frontend/app/components/FoldableBorderedContainerWithCaption.tsx b/frontend/app/components/FoldableBorderedContainerWithCaption.tsx index 2d21b61..49b1d2e 100644 --- a/frontend/app/components/FoldableBorderedContainerWithCaption.tsx +++ b/frontend/app/components/FoldableBorderedContainerWithCaption.tsx @@ -4,42 +4,42 @@ import React, { useState } from "react"; import BorderedContainer from "./BorderedContainer"; type Props = { - caption: string; - children: React.ReactNode; + caption: string; + children: React.ReactNode; }; export default function FoldableBorderedContainerWithCaption({ - caption, - children, + caption, + children, }: Props) { - const [isOpen, setIsOpen] = useState(true); + const [isOpen, setIsOpen] = useState(true); - const handleToggle = () => { - setIsOpen((prev) => !prev); - }; + const handleToggle = () => { + setIsOpen((prev) => !prev); + }; - return ( - <BorderedContainer> - <div className="flex flex-col gap-4"> - <div className="flex items-center"> - <div className="flex-1 text-center"> - <h2 className="text-lg font-semibold">{caption}</h2> - </div> - <div className="flex-shrink-0"> - <button - onClick={handleToggle} - className="p-1 bg-gray-50 border-1 border-gray-300 rounded-sm" - > - <FontAwesomeIcon - icon={isOpen ? faChevronUp : faChevronDown} - fixedWidth - className="text-gray-500" - /> - </button> - </div> - </div> - <div className={isOpen ? "" : "hidden"}>{children}</div> - </div> - </BorderedContainer> - ); + return ( + <BorderedContainer> + <div className="flex flex-col gap-4"> + <div className="flex items-center"> + <div className="flex-1 text-center"> + <h2 className="text-lg font-semibold">{caption}</h2> + </div> + <div className="flex-shrink-0"> + <button + onClick={handleToggle} + className="p-1 bg-gray-50 border-1 border-gray-300 rounded-sm" + > + <FontAwesomeIcon + icon={isOpen ? faChevronUp : faChevronDown} + fixedWidth + className="text-gray-500" + /> + </button> + </div> + </div> + <div className={isOpen ? "" : "hidden"}>{children}</div> + </div> + </BorderedContainer> + ); } diff --git a/frontend/app/components/Gaming/CodeBlock.tsx b/frontend/app/components/Gaming/CodeBlock.tsx index 2107f94..f048a38 100644 --- a/frontend/app/components/Gaming/CodeBlock.tsx +++ b/frontend/app/components/Gaming/CodeBlock.tsx @@ -4,60 +4,60 @@ import { JSX, useLayoutEffect, useState } from "react"; import { type BundledLanguage, highlight } from "../../highlight"; type Props = { - code: string; - language: BundledLanguage; + code: string; + language: BundledLanguage; }; function Plaintext({ code }: { code: string }) { - const lines = code.split("\n"); - return ( - <pre> - <code> - {lines.map((line, i) => ( - <span key={i} className="line"> - {line} - {i < lines.length - 1 ? "\n" : ""} - </span> - ))} - </code> - </pre> - ); + const lines = code.split("\n"); + return ( + <pre> + <code> + {lines.map((line, i) => ( + <span key={i} className="line"> + {line} + {i < lines.length - 1 ? "\n" : ""} + </span> + ))} + </code> + </pre> + ); } export default function CodeBlock({ code, language }: Props) { - const [nodes, setNodes] = useState<JSX.Element | null>(null); - const [showCopied, setShowCopied] = useState(false); + const [nodes, setNodes] = useState<JSX.Element | null>(null); + const [showCopied, setShowCopied] = useState(false); - useLayoutEffect(() => { - highlight(code, language) - .then(setNodes) - .catch(() => setNodes(null)); - }, [code, language]); + useLayoutEffect(() => { + highlight(code, language) + .then(setNodes) + .catch(() => setNodes(null)); + }, [code, language]); - const handleCopy = () => { - navigator.clipboard.writeText(code).then(() => { - setShowCopied(true); - setTimeout(() => setShowCopied(false), 3000); - }); - }; + const handleCopy = () => { + navigator.clipboard.writeText(code).then(() => { + setShowCopied(true); + setTimeout(() => setShowCopied(false), 3000); + }); + }; - return ( - <div className="relative"> - {code !== "" && ( - <button - onClick={handleCopy} - className="absolute top-2 right-2 z-10 px-2 py-1 bg-white border border-gray-300 rounded shadow-md hover:bg-gray-100 transition-colors" - title="コードをコピーする" - > - <FontAwesomeIcon icon={faCopy} className="text-gray-600" /> - {showCopied && ( - <span className="ml-1 text-xs text-brand-600">Copied!</span> - )} - </button> - )} - <div className="shiki h-full w-full p-2 pr-12 bg-white rounded-lg border border-gray-300 whitespace-pre-wrap break-words"> - {nodes ?? <Plaintext code={code} />} - </div> - </div> - ); + return ( + <div className="relative"> + {code !== "" && ( + <button + onClick={handleCopy} + className="absolute top-2 right-2 z-10 px-2 py-1 bg-white border border-gray-300 rounded shadow-md hover:bg-gray-100 transition-colors" + title="コードをコピーする" + > + <FontAwesomeIcon icon={faCopy} className="text-gray-600" /> + {showCopied && ( + <span className="ml-1 text-xs text-brand-600">Copied!</span> + )} + </button> + )} + <div className="shiki h-full w-full p-2 pr-12 bg-white rounded-lg border border-gray-300 whitespace-pre-wrap break-words"> + {nodes ?? <Plaintext code={code} />} + </div> + </div> + ); } diff --git a/frontend/app/components/Gaming/CodePopover.tsx b/frontend/app/components/Gaming/CodePopover.tsx index 91245df..c4065ed 100644 --- a/frontend/app/components/Gaming/CodePopover.tsx +++ b/frontend/app/components/Gaming/CodePopover.tsx @@ -7,39 +7,39 @@ import BorderedContainer from "../BorderedContainer"; import CodeBlock from "../Gaming/CodeBlock"; type Props = { - code: string; - language: SupportedLanguage; + code: string; + language: SupportedLanguage; }; export default function CodePopover({ code, language }: Props) { - const codeSize = calcCodeSize(code, language); + const codeSize = calcCodeSize(code, language); - return ( - <Popover.Root> - <Popover.Trigger> - <FontAwesomeIcon icon={faCode} fixedWidth /> - </Popover.Trigger> - <Popover.Portal> - <Popover.Positioner> - <Popover.Popup> - <BorderedContainer className="grow flex flex-col gap-4"> - <div className="flex flex-row gap-2 items-center"> - <div className="grow font-semibold text-lg"> - コードサイズ: {codeSize} - </div> - <Popover.Close className="p-1 bg-gray-50 border-1 border-gray-300 rounded-sm"> - <FontAwesomeIcon - icon={faXmark} - fixedWidth - className="text-gray-500" - /> - </Popover.Close> - </div> - <CodeBlock code={code} language={language} /> - </BorderedContainer> - </Popover.Popup> - </Popover.Positioner> - </Popover.Portal> - </Popover.Root> - ); + return ( + <Popover.Root> + <Popover.Trigger> + <FontAwesomeIcon icon={faCode} fixedWidth /> + </Popover.Trigger> + <Popover.Portal> + <Popover.Positioner> + <Popover.Popup> + <BorderedContainer className="grow flex flex-col gap-4"> + <div className="flex flex-row gap-2 items-center"> + <div className="grow font-semibold text-lg"> + コードサイズ: {codeSize} + </div> + <Popover.Close className="p-1 bg-gray-50 border-1 border-gray-300 rounded-sm"> + <FontAwesomeIcon + icon={faXmark} + fixedWidth + className="text-gray-500" + /> + </Popover.Close> + </div> + <CodeBlock code={code} language={language} /> + </BorderedContainer> + </Popover.Popup> + </Popover.Positioner> + </Popover.Portal> + </Popover.Root> + ); } diff --git a/frontend/app/components/Gaming/DataTable.test.tsx b/frontend/app/components/Gaming/DataTable.test.tsx index 2a4446c..08c7336 100644 --- a/frontend/app/components/Gaming/DataTable.test.tsx +++ b/frontend/app/components/Gaming/DataTable.test.tsx @@ -6,65 +6,65 @@ import { afterEach, describe, expect, test } from "vitest"; import DataTable, { DataTableCell, formatUnixTimestamp } from "./DataTable"; afterEach(() => { - cleanup(); + cleanup(); }); describe("DataTable", () => { - test("renders headers", () => { - render( - <DataTable headers={["A", "B", "C"]}> - <tr> - <DataTableCell>1</DataTableCell> - <DataTableCell>2</DataTableCell> - <DataTableCell>3</DataTableCell> - </tr> - </DataTable>, - ); - expect(screen.getByText("A")).toBeDefined(); - expect(screen.getByText("B")).toBeDefined(); - expect(screen.getByText("C")).toBeDefined(); - }); + test("renders headers", () => { + render( + <DataTable headers={["A", "B", "C"]}> + <tr> + <DataTableCell>1</DataTableCell> + <DataTableCell>2</DataTableCell> + <DataTableCell>3</DataTableCell> + </tr> + </DataTable>, + ); + expect(screen.getByText("A")).toBeDefined(); + expect(screen.getByText("B")).toBeDefined(); + expect(screen.getByText("C")).toBeDefined(); + }); - test("renders body cells", () => { - render( - <DataTable headers={["H"]}> - <tr> - <DataTableCell>cell content</DataTableCell> - </tr> - </DataTable>, - ); - expect(screen.getByText("cell content")).toBeDefined(); - }); + test("renders body cells", () => { + render( + <DataTable headers={["H"]}> + <tr> + <DataTableCell>cell content</DataTableCell> + </tr> + </DataTable>, + ); + expect(screen.getByText("cell content")).toBeDefined(); + }); - test("renders multiple rows", () => { - render( - <DataTable headers={["Name"]}> - <tr> - <DataTableCell>Alice</DataTableCell> - </tr> - <tr> - <DataTableCell>Bob</DataTableCell> - </tr> - </DataTable>, - ); - expect(screen.getByText("Alice")).toBeDefined(); - expect(screen.getByText("Bob")).toBeDefined(); - }); + test("renders multiple rows", () => { + render( + <DataTable headers={["Name"]}> + <tr> + <DataTableCell>Alice</DataTableCell> + </tr> + <tr> + <DataTableCell>Bob</DataTableCell> + </tr> + </DataTable>, + ); + expect(screen.getByText("Alice")).toBeDefined(); + expect(screen.getByText("Bob")).toBeDefined(); + }); }); describe("formatUnixTimestamp", () => { - test("formats timestamp correctly", () => { - // 2026-03-01 12:30 JST (UTC+9) = 2026-03-01 03:30 UTC - const timestamp = Date.UTC(2026, 2, 1, 3, 30, 0) / 1000; - const result = formatUnixTimestamp(timestamp); - // Result depends on local timezone; just check the format pattern - expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/); - }); + test("formats timestamp correctly", () => { + // 2026-03-01 12:30 JST (UTC+9) = 2026-03-01 03:30 UTC + const timestamp = Date.UTC(2026, 2, 1, 3, 30, 0) / 1000; + const result = formatUnixTimestamp(timestamp); + // Result depends on local timezone; just check the format pattern + expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/); + }); - test("pads single-digit months and days", () => { - // Use a date where month and day are single digits - const timestamp = Date.UTC(2026, 0, 5, 0, 0, 0) / 1000; - const result = formatUnixTimestamp(timestamp); - expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/); - }); + test("pads single-digit months and days", () => { + // Use a date where month and day are single digits + const timestamp = Date.UTC(2026, 0, 5, 0, 0, 0) / 1000; + const result = formatUnixTimestamp(timestamp); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/); + }); }); diff --git a/frontend/app/components/Gaming/DataTable.tsx b/frontend/app/components/Gaming/DataTable.tsx index 098f4a2..f909b1e 100644 --- a/frontend/app/components/Gaming/DataTable.tsx +++ b/frontend/app/components/Gaming/DataTable.tsx @@ -1,45 +1,45 @@ import type React from "react"; type Props = { - headers: React.ReactNode[]; - children: React.ReactNode; + headers: React.ReactNode[]; + children: React.ReactNode; }; export default function DataTable({ headers, children }: Props) { - return ( - <div className="overflow-x-auto border-2 border-brand-600 rounded-xl"> - <table className="min-w-full divide-y divide-gray-400 border-collapse"> - <thead className="bg-gray-50"> - <tr> - {headers.map((header, i) => ( - <th - key={i} - scope="col" - className="px-6 py-3 text-left font-medium text-gray-800" - > - {header} - </th> - ))} - </tr> - </thead> - <tbody className="bg-white divide-y divide-gray-300">{children}</tbody> - </table> - </div> - ); + return ( + <div className="overflow-x-auto border-2 border-brand-600 rounded-xl"> + <table className="min-w-full divide-y divide-gray-400 border-collapse"> + <thead className="bg-gray-50"> + <tr> + {headers.map((header, i) => ( + <th + key={i} + scope="col" + className="px-6 py-3 text-left font-medium text-gray-800" + > + {header} + </th> + ))} + </tr> + </thead> + <tbody className="bg-white divide-y divide-gray-300">{children}</tbody> + </table> + </div> + ); } export function DataTableCell({ children }: { children: React.ReactNode }) { - return ( - <td className="px-6 py-4 whitespace-nowrap text-gray-900">{children}</td> - ); + return ( + <td className="px-6 py-4 whitespace-nowrap text-gray-900">{children}</td> + ); } export function formatUnixTimestamp(timestamp: number): string { - const date = new Date(timestamp * 1000); - const year = date.getFullYear(); - const month = (date.getMonth() + 1).toString().padStart(2, "0"); - const day = date.getDate().toString().padStart(2, "0"); - const hours = date.getHours().toString().padStart(2, "0"); - const minutes = date.getMinutes().toString().padStart(2, "0"); - return `${year}-${month}-${day} ${hours}:${minutes}`; + const date = new Date(timestamp * 1000); + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, "0"); + const day = date.getDate().toString().padStart(2, "0"); + const hours = date.getHours().toString().padStart(2, "0"); + const minutes = date.getMinutes().toString().padStart(2, "0"); + return `${year}-${month}-${day} ${hours}:${minutes}`; } diff --git a/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx b/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx index e4260ec..2bf088b 100644 --- a/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx +++ b/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx @@ -1,46 +1,46 @@ import { - faCircle, - faCircleCheck, - faCircleExclamation, - faRotate, + faCircle, + faCircleCheck, + faCircleExclamation, + faRotate, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import type { components } from "../../api/schema"; type Props = { - status: components["schemas"]["ExecutionStatus"]; + status: components["schemas"]["ExecutionStatus"]; }; export default function ExecStatusIndicatorIcon({ status }: Props) { - switch (status) { - case "none": - return ( - <FontAwesomeIcon icon={faCircle} fixedWidth className="text-gray-400" /> - ); - case "running": - return ( - <FontAwesomeIcon - icon={faRotate} - spin - fixedWidth - className="text-gray-700" - /> - ); - case "success": - return ( - <FontAwesomeIcon - icon={faCircleCheck} - fixedWidth - className="text-brand-500" - /> - ); - default: - return ( - <FontAwesomeIcon - icon={faCircleExclamation} - fixedWidth - className="text-red-500" - /> - ); - } + switch (status) { + case "none": + return ( + <FontAwesomeIcon icon={faCircle} fixedWidth className="text-gray-400" /> + ); + case "running": + return ( + <FontAwesomeIcon + icon={faRotate} + spin + fixedWidth + className="text-gray-700" + /> + ); + case "success": + return ( + <FontAwesomeIcon + icon={faCircleCheck} + fixedWidth + className="text-brand-500" + /> + ); + default: + return ( + <FontAwesomeIcon + icon={faCircleExclamation} + fixedWidth + className="text-red-500" + /> + ); + } } diff --git a/frontend/app/components/Gaming/InlineCode.tsx b/frontend/app/components/Gaming/InlineCode.tsx index c90cad4..0b5e061 100644 --- a/frontend/app/components/Gaming/InlineCode.tsx +++ b/frontend/app/components/Gaming/InlineCode.tsx @@ -1,11 +1,11 @@ type Props = { - code: string; + code: string; }; export default function InlineCode({ code }: Props) { - return ( - <code className="bg-gray-50 rounded-lg border border-gray-300 p-1"> - {code} - </code> - ); + return ( + <code className="bg-gray-50 rounded-lg border border-gray-300 p-1"> + {code} + </code> + ); } diff --git a/frontend/app/components/Gaming/LeftTime.test.tsx b/frontend/app/components/Gaming/LeftTime.test.tsx index 742d8eb..28f2fc4 100644 --- a/frontend/app/components/Gaming/LeftTime.test.tsx +++ b/frontend/app/components/Gaming/LeftTime.test.tsx @@ -6,42 +6,42 @@ import { afterEach, describe, expect, test } from "vitest"; import LeftTime from "./LeftTime"; afterEach(() => { - cleanup(); + cleanup(); }); describe("LeftTime", () => { - test("renders MM:SS format for short durations", () => { - render(<LeftTime sec={65} />); - expect(screen.getByText("01:05")).toBeDefined(); - }); + test("renders MM:SS format for short durations", () => { + render(<LeftTime sec={65} />); + expect(screen.getByText("01:05")).toBeDefined(); + }); - test("renders 00:00 for zero seconds", () => { - render(<LeftTime sec={0} />); - expect(screen.getByText("00:00")).toBeDefined(); - }); + test("renders 00:00 for zero seconds", () => { + render(<LeftTime sec={0} />); + expect(screen.getByText("00:00")).toBeDefined(); + }); - test("renders MM:SS with leading zeros", () => { - render(<LeftTime sec={5} />); - expect(screen.getByText("00:05")).toBeDefined(); - }); + test("renders MM:SS with leading zeros", () => { + render(<LeftTime sec={5} />); + expect(screen.getByText("00:05")).toBeDefined(); + }); - test("renders 59:59 for max MM:SS range", () => { - render(<LeftTime sec={3599} />); - expect(screen.getByText("59:59")).toBeDefined(); - }); + test("renders 59:59 for max MM:SS range", () => { + render(<LeftTime sec={3599} />); + expect(screen.getByText("59:59")).toBeDefined(); + }); - test("renders long format with hours", () => { - render(<LeftTime sec={3661} />); - expect(screen.getByText("1h 1m 1s")).toBeDefined(); - }); + test("renders long format with hours", () => { + render(<LeftTime sec={3661} />); + expect(screen.getByText("1h 1m 1s")).toBeDefined(); + }); - test("renders long format with days", () => { - render(<LeftTime sec={90061} />); - expect(screen.getByText("1d 1h 1m 1s")).toBeDefined(); - }); + test("renders long format with days", () => { + render(<LeftTime sec={90061} />); + expect(screen.getByText("1d 1h 1m 1s")).toBeDefined(); + }); - test("renders long format omitting zero day and minute", () => { - render(<LeftTime sec={3605} />); - expect(screen.getByText("1h 5s")).toBeDefined(); - }); + test("renders long format omitting zero day and minute", () => { + render(<LeftTime sec={3605} />); + expect(screen.getByText("1h 5s")).toBeDefined(); + }); }); diff --git a/frontend/app/components/Gaming/LeftTime.tsx b/frontend/app/components/Gaming/LeftTime.tsx index 5013c76..a7678d6 100644 --- a/frontend/app/components/Gaming/LeftTime.tsx +++ b/frontend/app/components/Gaming/LeftTime.tsx @@ -1,26 +1,26 @@ type Props = { - sec: number; + sec: number; }; export default function LeftTime({ sec }: Props) { - const s = sec % 60; - const m = Math.floor(sec / 60) % 60; - const h = Math.floor(sec / 3600) % 24; - const d = Math.floor(sec / 86400); + const s = sec % 60; + const m = Math.floor(sec / 60) % 60; + const h = Math.floor(sec / 3600) % 24; + const d = Math.floor(sec / 86400); - let leftTime = ""; - if (d > 0 || h > 0) { - // 1d 2h 3m 4s - leftTime = [ - d > 0 ? `${d}d` : "", - h > 0 ? `${h}h` : "", - m > 0 ? `${m}m` : "", - `${s}s`, - ].join(" "); - } else { - // 03:04 - leftTime = `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; - } + let leftTime = ""; + if (d > 0 || h > 0) { + // 1d 2h 3m 4s + leftTime = [ + d > 0 ? `${d}d` : "", + h > 0 ? `${h}h` : "", + m > 0 ? `${m}m` : "", + `${s}s`, + ].join(" "); + } else { + // 03:04 + leftTime = `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; + } - return <div className="text-2xl md:text-3xl">{leftTime}</div>; + return <div className="text-2xl md:text-3xl">{leftTime}</div>; } diff --git a/frontend/app/components/Gaming/ProblemColumn.tsx b/frontend/app/components/Gaming/ProblemColumn.tsx index a355ac4..40d0716 100644 --- a/frontend/app/components/Gaming/ProblemColumn.tsx +++ b/frontend/app/components/Gaming/ProblemColumn.tsx @@ -3,25 +3,25 @@ import TitledColumn from "../TitledColumn"; import ProblemColumnContent from "./ProblemColumnContent"; type Props = { - title: string; - description: string; - language: SupportedLanguage; - sampleCode: string; + title: string; + description: string; + language: SupportedLanguage; + sampleCode: string; }; export default function ProblemColumn({ - title, - description, - language, - sampleCode, + title, + description, + language, + sampleCode, }: Props) { - return ( - <TitledColumn title={title}> - <ProblemColumnContent - description={description} - sampleCode={sampleCode} - language={language} - /> - </TitledColumn> - ); + return ( + <TitledColumn title={title}> + <ProblemColumnContent + description={description} + sampleCode={sampleCode} + language={language} + /> + </TitledColumn> + ); } diff --git a/frontend/app/components/Gaming/ProblemColumnContent.tsx b/frontend/app/components/Gaming/ProblemColumnContent.tsx index 1a7cb36..bc6b43a 100644 --- a/frontend/app/components/Gaming/ProblemColumnContent.tsx +++ b/frontend/app/components/Gaming/ProblemColumnContent.tsx @@ -4,87 +4,87 @@ import CodeBlock from "./CodeBlock"; import InlineCode from "./InlineCode"; function PhpNotice() { - return ( - <FoldableBorderedContainerWithCaption caption="スコア計算・PHP 環境"> - <div className="text-gray-700 flex flex-col gap-2"> - <p> - スコアはコード中の全 ASCII - 空白文字を除去した後のバイト数です。また、先頭や末尾に置かれた PHP - タグ (<InlineCode code="<?php" />、<InlineCode code="<?" />、 - <InlineCode code="?>" />) はカウントされません。 - </p> - <p> - 同じスコアを出した場合、より提出が早かったプレイヤーの勝ちとなります。 - </p> - <p> - この環境の PHP バージョンは{" "} - <strong className="font-bold">8.5.3</strong> です。 mbstring - を除くほとんどの拡張は無効化されています。 - また、ファイルやネットワークアクセスはできません。 - </p> - <p> - テストの成否は、標準出力へ出力された文字列を比較して判定されます。 - 末尾の改行はあってもなくても構いません。 - 標準エラー出力の内容は無視されますが、fatal error - 等で実行が中断された場合は失敗扱いとなります。 - </p> - <p> - なお、 - <InlineCode code="error_reporting" /> は{" "} - <InlineCode code="E_ALL & ~E_WARNING & ~E_NOTICE & ~E_DEPRECATED" />{" "} - に設定されています。 - </p> - </div> - </FoldableBorderedContainerWithCaption> - ); + return ( + <FoldableBorderedContainerWithCaption caption="スコア計算・PHP 環境"> + <div className="text-gray-700 flex flex-col gap-2"> + <p> + スコアはコード中の全 ASCII + 空白文字を除去した後のバイト数です。また、先頭や末尾に置かれた PHP + タグ (<InlineCode code="<?php" />、<InlineCode code="<?" />、 + <InlineCode code="?>" />) はカウントされません。 + </p> + <p> + 同じスコアを出した場合、より提出が早かったプレイヤーの勝ちとなります。 + </p> + <p> + この環境の PHP バージョンは{" "} + <strong className="font-bold">8.5.3</strong> です。 mbstring + を除くほとんどの拡張は無効化されています。 + また、ファイルやネットワークアクセスはできません。 + </p> + <p> + テストの成否は、標準出力へ出力された文字列を比較して判定されます。 + 末尾の改行はあってもなくても構いません。 + 標準エラー出力の内容は無視されますが、fatal error + 等で実行が中断された場合は失敗扱いとなります。 + </p> + <p> + なお、 + <InlineCode code="error_reporting" /> は{" "} + <InlineCode code="E_ALL & ~E_WARNING & ~E_NOTICE & ~E_DEPRECATED" />{" "} + に設定されています。 + </p> + </div> + </FoldableBorderedContainerWithCaption> + ); } function SwiftNotice() { - return ( - <FoldableBorderedContainerWithCaption caption="スコア計算・Swift 環境"> - <div className="text-gray-700 flex flex-col gap-2"> - <p>スコアはコード中の全 ASCII 空白文字を除去した後のバイト数です。</p> - <p> - 同じスコアを出した場合、より提出が早かったプレイヤーの勝ちとなります。 - </p> - <p> - この環境の Swift バージョンは{" "} - <strong className="font-bold">6.1.2</strong> です。 - ファイルアクセスやネットワークアクセスはできません。 - </p> - <p> - テストの成否は、標準出力へ出力された文字列を比較して判定されます。 - 末尾の改行はあってもなくても構いません。 - 標準エラー出力の内容は無視されますが、fatal error - 等で実行が中断された場合は失敗扱いとなります。 - </p> - </div> - </FoldableBorderedContainerWithCaption> - ); + return ( + <FoldableBorderedContainerWithCaption caption="スコア計算・Swift 環境"> + <div className="text-gray-700 flex flex-col gap-2"> + <p>スコアはコード中の全 ASCII 空白文字を除去した後のバイト数です。</p> + <p> + 同じスコアを出した場合、より提出が早かったプレイヤーの勝ちとなります。 + </p> + <p> + この環境の Swift バージョンは{" "} + <strong className="font-bold">6.1.2</strong> です。 + ファイルアクセスやネットワークアクセスはできません。 + </p> + <p> + テストの成否は、標準出力へ出力された文字列を比較して判定されます。 + 末尾の改行はあってもなくても構いません。 + 標準エラー出力の内容は無視されますが、fatal error + 等で実行が中断された場合は失敗扱いとなります。 + </p> + </div> + </FoldableBorderedContainerWithCaption> + ); } type Props = { - description: string; - language: SupportedLanguage; - sampleCode: string; + description: string; + language: SupportedLanguage; + sampleCode: string; }; export default function ProblemColumnContent({ - description, - language, - sampleCode, + description, + language, + sampleCode, }: Props) { - return ( - <> - <FoldableBorderedContainerWithCaption caption="問題"> - <pre className="text-gray-700 whitespace-pre-wrap break-words"> - {description} - </pre> - </FoldableBorderedContainerWithCaption> - <FoldableBorderedContainerWithCaption caption="サンプルコード"> - <CodeBlock code={sampleCode} language={language} /> - </FoldableBorderedContainerWithCaption> - {language === "php" ? <PhpNotice /> : <SwiftNotice />} - </> - ); + return ( + <> + <FoldableBorderedContainerWithCaption caption="問題"> + <pre className="text-gray-700 whitespace-pre-wrap break-words"> + {description} + </pre> + </FoldableBorderedContainerWithCaption> + <FoldableBorderedContainerWithCaption caption="サンプルコード"> + <CodeBlock code={sampleCode} language={language} /> + </FoldableBorderedContainerWithCaption> + {language === "php" ? <PhpNotice /> : <SwiftNotice />} + </> + ); } diff --git a/frontend/app/components/Gaming/RankingTable.tsx b/frontend/app/components/Gaming/RankingTable.tsx index 60f4808..b0a6116 100644 --- a/frontend/app/components/Gaming/RankingTable.tsx +++ b/frontend/app/components/Gaming/RankingTable.tsx @@ -5,43 +5,43 @@ import CodePopover from "./CodePopover"; import DataTable, { DataTableCell, formatUnixTimestamp } from "./DataTable"; type Props = { - problemLanguage: SupportedLanguage; + problemLanguage: SupportedLanguage; }; export default function RankingTable({ problemLanguage }: Props) { - const ranking = useAtomValue(rankingAtom); - const showCode = ranking.some((entry) => entry.code != null); + const ranking = useAtomValue(rankingAtom); + const showCode = ranking.some((entry) => entry.code != null); - return ( - <DataTable - headers={[ - "順位", - "プレイヤー", - "スコア", - "提出時刻", - ...(showCode ? ["コード"] : []), - ]} - > - {ranking.map((entry, index) => ( - <tr key={entry.player.user_id}> - <DataTableCell>{index + 1}</DataTableCell> - <DataTableCell> - {entry.player.display_name} - {entry.player.label && ` (${entry.player.label})`} - </DataTableCell> - <DataTableCell>{entry.score}</DataTableCell> - <DataTableCell> - {formatUnixTimestamp(entry.submitted_at)} - </DataTableCell> - {showCode && ( - <DataTableCell> - {entry.code && ( - <CodePopover code={entry.code} language={problemLanguage} /> - )} - </DataTableCell> - )} - </tr> - ))} - </DataTable> - ); + return ( + <DataTable + headers={[ + "順位", + "プレイヤー", + "スコア", + "提出時刻", + ...(showCode ? ["コード"] : []), + ]} + > + {ranking.map((entry, index) => ( + <tr key={entry.player.user_id}> + <DataTableCell>{index + 1}</DataTableCell> + <DataTableCell> + {entry.player.display_name} + {entry.player.label && ` (${entry.player.label})`} + </DataTableCell> + <DataTableCell>{entry.score}</DataTableCell> + <DataTableCell> + {formatUnixTimestamp(entry.submitted_at)} + </DataTableCell> + {showCode && ( + <DataTableCell> + {entry.code && ( + <CodePopover code={entry.code} language={problemLanguage} /> + )} + </DataTableCell> + )} + </tr> + ))} + </DataTable> + ); } diff --git a/frontend/app/components/Gaming/Score.tsx b/frontend/app/components/Gaming/Score.tsx index ee23a6c..8e1e61d 100644 --- a/frontend/app/components/Gaming/Score.tsx +++ b/frontend/app/components/Gaming/Score.tsx @@ -1,36 +1,36 @@ import { useEffect, useState } from "react"; type Props = { - status: string | null; - score: number | null; + status: string | null; + score: number | null; }; export default function Score({ status, score }: Props) { - const [randomScore, setRandomScore] = useState<number | null>(null); + const [randomScore, setRandomScore] = useState<number | null>(null); - useEffect(() => { - if (status !== "running") { - return; - } + useEffect(() => { + if (status !== "running") { + return; + } - const intervalId = setInterval(() => { - const maxValue = Math.pow(10, String(score ?? 100).length) - 1; - const minValue = Math.pow(10, String(score ?? 100).length - 1); - const randomValue = - Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue; - setRandomScore(randomValue); - }, 50); + const intervalId = setInterval(() => { + const maxValue = Math.pow(10, String(score ?? 100).length) - 1; + const minValue = Math.pow(10, String(score ?? 100).length - 1); + const randomValue = + Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue; + setRandomScore(randomValue); + }, 50); - return () => { - clearInterval(intervalId); - }; - }, [status, score]); + return () => { + clearInterval(intervalId); + }; + }, [status, score]); - const displayScore = status === "running" ? randomScore : score; + const displayScore = status === "running" ? randomScore : score; - return ( - <span className={status === "running" ? "animate-pulse" : ""}> - {displayScore} - </span> - ); + return ( + <span className={status === "running" ? "animate-pulse" : ""}> + {displayScore} + </span> + ); } diff --git a/frontend/app/components/Gaming/ScoreBar.tsx b/frontend/app/components/Gaming/ScoreBar.tsx index 6a291cd..50a2402 100644 --- a/frontend/app/components/Gaming/ScoreBar.tsx +++ b/frontend/app/components/Gaming/ScoreBar.tsx @@ -1,30 +1,30 @@ type Props = { - scoreA: number | null; - scoreB: number | null; - bgA: string; - bgB: string; + scoreA: number | null; + scoreB: number | null; + bgA: string; + bgB: string; }; export default function ScoreBar({ scoreA, scoreB, bgA, bgB }: Props) { - let scoreRatio; - if (scoreA === null && scoreB === null) { - scoreRatio = 50; - } else if (scoreA === null) { - scoreRatio = 0; - } else if (scoreB === null) { - scoreRatio = 100; - } else { - const rawRatio = scoreB / (scoreA + scoreB); - const k = 3.0; - const emphasizedRatio = - Math.pow(rawRatio, k) / - (Math.pow(rawRatio, k) + Math.pow(1 - rawRatio, k)); - scoreRatio = emphasizedRatio * 100; - } + let scoreRatio; + if (scoreA === null && scoreB === null) { + scoreRatio = 50; + } else if (scoreA === null) { + scoreRatio = 0; + } else if (scoreB === null) { + scoreRatio = 100; + } else { + const rawRatio = scoreB / (scoreA + scoreB); + const k = 3.0; + const emphasizedRatio = + Math.pow(rawRatio, k) / + (Math.pow(rawRatio, k) + Math.pow(1 - rawRatio, k)); + scoreRatio = emphasizedRatio * 100; + } - return ( - <div className={`w-full ${bgB}`}> - <div className={`h-10 ${bgA}`} style={{ width: `${scoreRatio}%` }}></div> - </div> - ); + return ( + <div className={`w-full ${bgB}`}> + <div className={`h-10 ${bgA}`} style={{ width: `${scoreRatio}%` }}></div> + </div> + ); } diff --git a/frontend/app/components/GolfPlayApp.tsx b/frontend/app/components/GolfPlayApp.tsx index 5d00239..e9fa410 100644 --- a/frontend/app/components/GolfPlayApp.tsx +++ b/frontend/app/components/GolfPlayApp.tsx @@ -6,13 +6,13 @@ import { useDebouncedCallback } from "use-debounce"; import { ApiClientContext } from "../api/client"; import type { components } from "../api/schema"; import { - gameStateKindAtom, - handleSubmitCodePostAtom, - handleSubmitCodePreAtom, - setCurrentTimestampAtom, - setDurationSecondsAtom, - setGameStartedAtAtom, - setLatestGameStateAtom, + gameStateKindAtom, + handleSubmitCodePostAtom, + handleSubmitCodePreAtom, + setCurrentTimestampAtom, + setDurationSecondsAtom, + setGameStartedAtAtom, + setLatestGameStateAtom, } from "../states/play"; import GolfPlayAppGaming from "./GolfPlayApps/GolfPlayAppGaming"; import GolfPlayAppLoading from "./GolfPlayApps/GolfPlayAppLoading"; @@ -25,163 +25,163 @@ type Submission = components["schemas"]["Submission"]; type LatestGameState = components["schemas"]["LatestGameState"]; type Props = { - game: Game; - player: User; - initialGameState: LatestGameState; + game: Game; + player: User; + initialGameState: LatestGameState; }; export default function GolfPlayApp({ game, player, initialGameState }: Props) { - useHydrateAtoms([ - [setDurationSecondsAtom, game.duration_seconds], - [setGameStartedAtAtom, game.started_at ?? null], - [setLatestGameStateAtom, initialGameState], - ]); + useHydrateAtoms([ + [setDurationSecondsAtom, game.duration_seconds], + [setGameStartedAtAtom, game.started_at ?? null], + [setLatestGameStateAtom, initialGameState], + ]); - const apiClient = useContext(ApiClientContext)!; + const apiClient = useContext(ApiClientContext)!; - const gameStateKind = useAtomValue(gameStateKindAtom); - const setGameStartedAt = useSetAtom(setGameStartedAtAtom); - const setCurrentTimestamp = useSetAtom(setCurrentTimestampAtom); - const handleSubmitCodePre = useSetAtom(handleSubmitCodePreAtom); - const handleSubmitCodePost = useSetAtom(handleSubmitCodePostAtom); - const setLatestGameState = useSetAtom(setLatestGameStateAtom); + 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); + useTimer({ delay: 1000, startImmediately: true }, setCurrentTimestamp); - const playerProfile = { - id: player.user_id, - displayName: player.display_name, - iconPath: player.icon_path ?? null, - }; + const playerProfile = { + id: player.user_id, + displayName: player.display_name, + iconPath: player.icon_path ?? null, + }; - const onCodeChange = useDebouncedCallback(async (code: string) => { - if (game.game_type === "1v1") { - console.log("player:c2s:code"); - await apiClient.postGamePlayCode(game.game_id, code); - } - }, 1000); + const onCodeChange = useDebouncedCallback(async (code: string) => { + if (game.game_type === "1v1") { + console.log("player:c2s:code"); + await apiClient.postGamePlayCode(game.game_id, code); + } + }, 1000); - const onCodeSubmit = useDebouncedCallback( - async (code: string) => { - if (code === "") { - return; - } - console.log("player:c2s:submit"); - handleSubmitCodePre(); - await apiClient.postGamePlaySubmit(game.game_id, code); - await new Promise((resolve) => setTimeout(resolve, 1000)); - handleSubmitCodePost(); - }, - 1000, - { leading: true }, - ); + const onCodeSubmit = useDebouncedCallback( + async (code: string) => { + if (code === "") { + return; + } + console.log("player:c2s:submit"); + handleSubmitCodePre(); + await apiClient.postGamePlaySubmit(game.game_id, code); + await new Promise((resolve) => setTimeout(resolve, 1000)); + handleSubmitCodePost(); + }, + 1000, + { leading: true }, + ); - const [submissions, setSubmissions] = useState<Submission[]>([]); - const [isDataPolling, setIsDataPolling] = useState(false); + const [submissions, setSubmissions] = useState<Submission[]>([]); + const [isDataPolling, setIsDataPolling] = useState(false); - const fetchSubmissions = useCallback(async () => { - try { - const { submissions } = await apiClient.getGamePlaySubmissions( - game.game_id, - ); - setSubmissions(submissions); - } catch (error) { - console.error(error); - } - }, [apiClient, game.game_id]); + const fetchSubmissions = useCallback(async () => { + try { + const { submissions } = await apiClient.getGamePlaySubmissions( + game.game_id, + ); + setSubmissions(submissions); + } catch (error) { + console.error(error); + } + }, [apiClient, game.game_id]); - useEffect(() => { - if (gameStateKind === "finished") { - fetchSubmissions(); - } - }, [gameStateKind, fetchSubmissions]); + useEffect(() => { + if (gameStateKind === "finished") { + fetchSubmissions(); + } + }, [gameStateKind, fetchSubmissions]); - useEffect(() => { - if (isDataPolling) { - return; - } - const timerId = setInterval(async () => { - if (isDataPolling) { - return; - } - setIsDataPolling(true); + useEffect(() => { + if (isDataPolling) { + return; + } + const timerId = setInterval(async () => { + if (isDataPolling) { + return; + } + setIsDataPolling(true); - try { - if (gameStateKind === "waiting") { - const { game: g } = await apiClient.getGame(game.game_id); - if (g.started_at != null) { - setGameStartedAt(g.started_at); - } - } else if (gameStateKind === "gaming") { - const { state } = await apiClient.getGamePlayLatestState( - game.game_id, - ); - setLatestGameState(state); - await fetchSubmissions(); - } - } catch (error) { - console.error(error); - } finally { - setIsDataPolling(false); - } - }, 1000); + try { + if (gameStateKind === "waiting") { + const { game: g } = await apiClient.getGame(game.game_id); + if (g.started_at != null) { + setGameStartedAt(g.started_at); + } + } else if (gameStateKind === "gaming") { + const { state } = await apiClient.getGamePlayLatestState( + game.game_id, + ); + setLatestGameState(state); + await fetchSubmissions(); + } + } catch (error) { + console.error(error); + } finally { + setIsDataPolling(false); + } + }, 1000); - return () => { - clearInterval(timerId); - }; - }, [ - isDataPolling, - apiClient, - game.game_id, - gameStateKind, - setGameStartedAt, - setLatestGameState, - fetchSubmissions, - ]); + return () => { + clearInterval(timerId); + }; + }, [ + isDataPolling, + apiClient, + game.game_id, + gameStateKind, + setGameStartedAt, + setLatestGameState, + fetchSubmissions, + ]); - if (gameStateKind === "loading") { - return <GolfPlayAppLoading />; - } else if (gameStateKind === "waiting") { - if (player.is_admin) { - return ( - <GolfPlayAppGaming - gameDisplayName={game.display_name} - playerProfile={playerProfile} - problemTitle={game.problem.title} - problemDescription={game.problem.description} - problemLanguage={game.problem.language} - sampleCode={game.problem.sample_code} - initialCode={initialGameState.code} - onCodeChange={onCodeChange} - onCodeSubmit={onCodeSubmit} - isFinished={false} - submissions={submissions} - /> - ); - } - return ( - <GolfPlayAppWaiting - gameDisplayName={game.display_name} - playerProfile={playerProfile} - /> - ); - } else if (gameStateKind === "starting") { - return <GolfPlayAppStarting gameDisplayName={game.display_name} />; - } else if (gameStateKind === "gaming" || gameStateKind === "finished") { - return ( - <GolfPlayAppGaming - gameDisplayName={game.display_name} - playerProfile={playerProfile} - problemTitle={game.problem.title} - problemDescription={game.problem.description} - problemLanguage={game.problem.language} - sampleCode={game.problem.sample_code} - initialCode={initialGameState.code} - onCodeChange={onCodeChange} - onCodeSubmit={onCodeSubmit} - isFinished={gameStateKind === "finished"} - submissions={submissions} - /> - ); - } + if (gameStateKind === "loading") { + return <GolfPlayAppLoading />; + } else if (gameStateKind === "waiting") { + if (player.is_admin) { + return ( + <GolfPlayAppGaming + gameDisplayName={game.display_name} + playerProfile={playerProfile} + problemTitle={game.problem.title} + problemDescription={game.problem.description} + problemLanguage={game.problem.language} + sampleCode={game.problem.sample_code} + initialCode={initialGameState.code} + onCodeChange={onCodeChange} + onCodeSubmit={onCodeSubmit} + isFinished={false} + submissions={submissions} + /> + ); + } + return ( + <GolfPlayAppWaiting + gameDisplayName={game.display_name} + playerProfile={playerProfile} + /> + ); + } else if (gameStateKind === "starting") { + return <GolfPlayAppStarting gameDisplayName={game.display_name} />; + } else if (gameStateKind === "gaming" || gameStateKind === "finished") { + return ( + <GolfPlayAppGaming + gameDisplayName={game.display_name} + playerProfile={playerProfile} + problemTitle={game.problem.title} + problemDescription={game.problem.description} + problemLanguage={game.problem.language} + sampleCode={game.problem.sample_code} + initialCode={initialGameState.code} + onCodeChange={onCodeChange} + onCodeSubmit={onCodeSubmit} + isFinished={gameStateKind === "finished"} + submissions={submissions} + /> + ); + } } diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.test.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.test.tsx index 2d51d66..ae5381b 100644 --- a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.test.tsx +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.test.tsx @@ -5,103 +5,103 @@ import { cleanup, render, screen } from "@testing-library/react"; import { createStore, Provider } from "jotai"; import { afterEach, describe, expect, test } from "vitest"; import { - setCurrentTimestampAtom, - setDurationSecondsAtom, - setGameStartedAtAtom, - setLatestGameStateAtom, + setCurrentTimestampAtom, + setDurationSecondsAtom, + setGameStartedAtAtom, + setLatestGameStateAtom, } from "../../states/play"; import GolfPlayAppGaming from "./GolfPlayAppGaming"; afterEach(() => { - cleanup(); + cleanup(); }); function createTestStore() { - const store = createStore(); - const now = Math.floor(Date.now() / 1000); - store.set(setCurrentTimestampAtom); - store.set(setDurationSecondsAtom, 600); - store.set(setGameStartedAtAtom, now - 60); - store.set(setLatestGameStateAtom, { - status: "none", - code: "", - score: null, - best_score_submitted_at: null, - }); - return store; + const store = createStore(); + const now = Math.floor(Date.now() / 1000); + store.set(setCurrentTimestampAtom); + store.set(setDurationSecondsAtom, 600); + store.set(setGameStartedAtAtom, now - 60); + store.set(setLatestGameStateAtom, { + status: "none", + code: "", + score: null, + best_score_submitted_at: null, + }); + return store; } const defaultProps = { - gameDisplayName: "Test Game", - playerProfile: { - id: 1, - displayName: "Test Player", - iconPath: null, - }, - problemTitle: "Test Problem", - problemDescription: "Description", - problemLanguage: "php" as const, - sampleCode: "<?php echo 1;", - initialCode: "", - onCodeChange: () => {}, - onCodeSubmit: () => {}, - isFinished: false, + gameDisplayName: "Test Game", + playerProfile: { + id: 1, + displayName: "Test Player", + iconPath: null, + }, + problemTitle: "Test Problem", + problemDescription: "Description", + problemLanguage: "php" as const, + sampleCode: "<?php echo 1;", + initialCode: "", + onCodeChange: () => {}, + onCodeSubmit: () => {}, + isFinished: false, }; describe("GolfPlayAppGaming submission history", () => { - test("shows placeholder row when no submissions", () => { - const store = createTestStore(); - render( - <Provider store={store}> - <GolfPlayAppGaming {...defaultProps} submissions={[]} /> - </Provider>, - ); - expect(screen.getByText("提出待ち")).toBeDefined(); - const dashes = screen.getAllByText("-"); - expect(dashes.length).toBe(3); - }); + test("shows placeholder row when no submissions", () => { + const store = createTestStore(); + render( + <Provider store={store}> + <GolfPlayAppGaming {...defaultProps} submissions={[]} /> + </Provider>, + ); + expect(screen.getByText("提出待ち")).toBeDefined(); + const dashes = screen.getAllByText("-"); + expect(dashes.length).toBe(3); + }); - test("renders submission rows with status and code size", () => { - const store = createTestStore(); - const submissions = [ - { - submission_id: 1, - game_id: 1, - status: "success" as const, - code: "<?php echo 1;", - code_size: 7, - created_at: 1740000000, - }, - { - submission_id: 2, - game_id: 1, - status: "wrong_answer" as const, - code: "<?php echo 2;", - code_size: 10, - created_at: 1740000060, - }, - ]; - render( - <Provider store={store}> - <GolfPlayAppGaming {...defaultProps} submissions={submissions} /> - </Provider>, - ); - expect(screen.getByText("成功")).toBeDefined(); - expect(screen.getByText("テスト失敗")).toBeDefined(); - expect(screen.getByText("7")).toBeDefined(); - expect(screen.getByText("10")).toBeDefined(); - }); + test("renders submission rows with status and code size", () => { + const store = createTestStore(); + const submissions = [ + { + submission_id: 1, + game_id: 1, + status: "success" as const, + code: "<?php echo 1;", + code_size: 7, + created_at: 1740000000, + }, + { + submission_id: 2, + game_id: 1, + status: "wrong_answer" as const, + code: "<?php echo 2;", + code_size: 10, + created_at: 1740000060, + }, + ]; + render( + <Provider store={store}> + <GolfPlayAppGaming {...defaultProps} submissions={submissions} /> + </Provider>, + ); + expect(screen.getByText("成功")).toBeDefined(); + expect(screen.getByText("テスト失敗")).toBeDefined(); + expect(screen.getByText("7")).toBeDefined(); + expect(screen.getByText("10")).toBeDefined(); + }); - test("renders table headers", () => { - const store = createTestStore(); - render( - <Provider store={store}> - <GolfPlayAppGaming {...defaultProps} submissions={[]} /> - </Provider>, - ); - expect(screen.getByText("ステータス")).toBeDefined(); - expect(screen.getByText("スコア")).toBeDefined(); - expect(screen.getByText("提出時刻")).toBeDefined(); - expect(screen.getByText("コード")).toBeDefined(); - }); + test("renders table headers", () => { + const store = createTestStore(); + render( + <Provider store={store}> + <GolfPlayAppGaming {...defaultProps} submissions={[]} /> + </Provider>, + ); + expect(screen.getByText("ステータス")).toBeDefined(); + expect(screen.getByText("スコア")).toBeDefined(); + expect(screen.getByText("提出時刻")).toBeDefined(); + expect(screen.getByText("コード")).toBeDefined(); + }); }); diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx index e590df0..3e1ab67 100644 --- a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx @@ -3,18 +3,18 @@ import React, { useRef, useState } from "react"; import { Link } from "wouter"; import type { components } from "../../api/schema"; import { - calcCodeSize, - gamingLeftTimeSecondsAtom, - scoreAtom, - statusAtom, + calcCodeSize, + gamingLeftTimeSecondsAtom, + scoreAtom, + statusAtom, } from "../../states/play"; import type { PlayerProfile } from "../../types/PlayerProfile"; import type { SupportedLanguage } from "../../types/SupportedLanguage"; import BorderedContainer from "../BorderedContainer"; import CodePopover from "../Gaming/CodePopover"; import DataTable, { - DataTableCell, - formatUnixTimestamp, + DataTableCell, + formatUnixTimestamp, } from "../Gaming/DataTable"; import LeftTime from "../Gaming/LeftTime"; import ProblemColumn from "../Gaming/ProblemColumn"; @@ -27,142 +27,142 @@ import UserIcon from "../UserIcon"; type Submission = components["schemas"]["Submission"]; type Props = { - gameDisplayName: string; - playerProfile: PlayerProfile; - problemTitle: string; - problemDescription: string; - problemLanguage: SupportedLanguage; - sampleCode: string; - initialCode: string; - onCodeChange: (code: string) => void; - onCodeSubmit: (code: string) => void; - isFinished: boolean; - submissions: Submission[]; + gameDisplayName: string; + playerProfile: PlayerProfile; + problemTitle: string; + problemDescription: string; + problemLanguage: SupportedLanguage; + sampleCode: string; + initialCode: string; + onCodeChange: (code: string) => void; + onCodeSubmit: (code: string) => void; + isFinished: boolean; + submissions: Submission[]; }; export default function GolfPlayAppGaming({ - gameDisplayName, - playerProfile, - problemTitle, - problemDescription, - problemLanguage, - sampleCode, - initialCode, - onCodeChange, - onCodeSubmit, - isFinished, - submissions, + gameDisplayName, + playerProfile, + problemTitle, + problemDescription, + problemLanguage, + sampleCode, + initialCode, + onCodeChange, + onCodeSubmit, + isFinished, + submissions, }: Props) { - const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom); - const score = useAtomValue(scoreAtom); - const status = useAtomValue(statusAtom); + const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom); + const score = useAtomValue(scoreAtom); + const status = useAtomValue(statusAtom); - const [codeSize, setCodeSize] = useState( - calcCodeSize(initialCode, problemLanguage), - ); - const textareaRef = useRef<HTMLTextAreaElement>(null); + const [codeSize, setCodeSize] = useState( + calcCodeSize(initialCode, problemLanguage), + ); + const textareaRef = useRef<HTMLTextAreaElement>(null); - const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { - setCodeSize(calcCodeSize(e.target.value, problemLanguage)); - if (!isFinished) { - onCodeChange(e.target.value); - } - }; + const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { + setCodeSize(calcCodeSize(e.target.value, problemLanguage)); + if (!isFinished) { + onCodeChange(e.target.value); + } + }; - const handleSubmitButtonClick = () => { - if (textareaRef.current && !isFinished) { - onCodeSubmit(textareaRef.current.value); - } - }; + const handleSubmitButtonClick = () => { + if (textareaRef.current && !isFinished) { + onCodeSubmit(textareaRef.current.value); + } + }; - return ( - <div className="min-h-screen bg-gray-100 flex flex-col"> - <div className="text-white bg-brand-600 flex flex-row justify-between px-4 py-2"> - <div className="font-bold"> - <div className="text-gray-100">{gameDisplayName}</div> - {isFinished ? ( - <div className="text-2xl md:text-3xl">終了</div> - ) : leftTimeSeconds === null ? ( - <div className="text-2xl md:text-3xl">未開始</div> - ) : ( - <LeftTime sec={leftTimeSeconds} /> - )} - </div> - <Link to={"/dashboard"}> - <div className="flex gap-6 items-center font-bold"> - <div className="text-2xl md:text-6xl">{score}</div> - <div className="hidden md:block text-4xl"> - {playerProfile.displayName} - </div> - {playerProfile.iconPath && ( - <UserIcon - iconPath={playerProfile.iconPath} - displayName={playerProfile.displayName} - className="w-12 h-12 my-auto" - /> - )} - </div> - </Link> - </div> - <ThreeColumnLayout> - <ProblemColumn - title={problemTitle} - description={problemDescription} - language={problemLanguage} - sampleCode={sampleCode} - /> - <TitledColumn title="ソースコード"> - <BorderedContainer className="grow flex flex-col gap-4"> - <div className="flex flex-row gap-2 items-center"> - <div className="grow font-semibold text-lg"> - コードサイズ: {codeSize} - </div> - <SubmitButton - onClick={handleSubmitButtonClick} - disabled={isFinished} - > - 提出 - </SubmitButton> - </div> - <textarea - ref={textareaRef} - defaultValue={initialCode} - onChange={handleTextChange} - className="grow resize-none h-full w-full p-2 bg-gray-50 rounded-lg border border-gray-300 focus:outline-hidden focus:ring-2 focus:ring-gray-400 transition duration-300" - rows={10} - /> - </BorderedContainer> - </TitledColumn> - <TitledColumn title="提出結果"> - <DataTable headers={["ステータス", "スコア", "提出時刻", "コード"]}> - {submissions.length === 0 ? ( - <tr> - <DataTableCell> - <SubmitStatusLabel status={status} /> - </DataTableCell> - <DataTableCell>-</DataTableCell> - <DataTableCell>-</DataTableCell> - <DataTableCell>-</DataTableCell> - </tr> - ) : ( - submissions.map((s) => ( - <tr key={s.submission_id}> - <DataTableCell> - <SubmitStatusLabel status={s.status} /> - </DataTableCell> - <DataTableCell>{s.code_size}</DataTableCell> - <DataTableCell> - {formatUnixTimestamp(s.created_at)} - </DataTableCell> - <DataTableCell> - <CodePopover code={s.code} language={problemLanguage} /> - </DataTableCell> - </tr> - )) - )} - </DataTable> - </TitledColumn> - </ThreeColumnLayout> - </div> - ); + return ( + <div className="min-h-screen bg-gray-100 flex flex-col"> + <div className="text-white bg-brand-600 flex flex-row justify-between px-4 py-2"> + <div className="font-bold"> + <div className="text-gray-100">{gameDisplayName}</div> + {isFinished ? ( + <div className="text-2xl md:text-3xl">終了</div> + ) : leftTimeSeconds === null ? ( + <div className="text-2xl md:text-3xl">未開始</div> + ) : ( + <LeftTime sec={leftTimeSeconds} /> + )} + </div> + <Link to={"/dashboard"}> + <div className="flex gap-6 items-center font-bold"> + <div className="text-2xl md:text-6xl">{score}</div> + <div className="hidden md:block text-4xl"> + {playerProfile.displayName} + </div> + {playerProfile.iconPath && ( + <UserIcon + iconPath={playerProfile.iconPath} + displayName={playerProfile.displayName} + className="w-12 h-12 my-auto" + /> + )} + </div> + </Link> + </div> + <ThreeColumnLayout> + <ProblemColumn + title={problemTitle} + description={problemDescription} + language={problemLanguage} + sampleCode={sampleCode} + /> + <TitledColumn title="ソースコード"> + <BorderedContainer className="grow flex flex-col gap-4"> + <div className="flex flex-row gap-2 items-center"> + <div className="grow font-semibold text-lg"> + コードサイズ: {codeSize} + </div> + <SubmitButton + onClick={handleSubmitButtonClick} + disabled={isFinished} + > + 提出 + </SubmitButton> + </div> + <textarea + ref={textareaRef} + defaultValue={initialCode} + onChange={handleTextChange} + className="grow resize-none h-full w-full p-2 bg-gray-50 rounded-lg border border-gray-300 focus:outline-hidden focus:ring-2 focus:ring-gray-400 transition duration-300" + rows={10} + /> + </BorderedContainer> + </TitledColumn> + <TitledColumn title="提出結果"> + <DataTable headers={["ステータス", "スコア", "提出時刻", "コード"]}> + {submissions.length === 0 ? ( + <tr> + <DataTableCell> + <SubmitStatusLabel status={status} /> + </DataTableCell> + <DataTableCell>-</DataTableCell> + <DataTableCell>-</DataTableCell> + <DataTableCell>-</DataTableCell> + </tr> + ) : ( + submissions.map((s) => ( + <tr key={s.submission_id}> + <DataTableCell> + <SubmitStatusLabel status={s.status} /> + </DataTableCell> + <DataTableCell>{s.code_size}</DataTableCell> + <DataTableCell> + {formatUnixTimestamp(s.created_at)} + </DataTableCell> + <DataTableCell> + <CodePopover code={s.code} language={problemLanguage} /> + </DataTableCell> + </tr> + )) + )} + </DataTable> + </TitledColumn> + </ThreeColumnLayout> + </div> + ); } diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppLoading.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppLoading.tsx index 7b424f2..ccb716f 100644 --- a/frontend/app/components/GolfPlayApps/GolfPlayAppLoading.tsx +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppLoading.tsx @@ -1,9 +1,9 @@ export default function GolfPlayAppLoading() { - 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> - ); + 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/GolfPlayAppStarting.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx index b3378fc..951e001 100644 --- a/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx @@ -2,20 +2,20 @@ import { useAtomValue } from "jotai"; import { startingLeftTimeSecondsAtom } from "../../states/play"; type Props = { - gameDisplayName: string; + gameDisplayName: string; }; export default function GolfPlayAppStarting({ gameDisplayName }: Props) { - const leftTimeSeconds = useAtomValue(startingLeftTimeSecondsAtom)!; + const leftTimeSeconds = useAtomValue(startingLeftTimeSecondsAtom)!; - return ( - <div className="min-h-screen bg-gray-100 flex flex-col"> - <div className="text-white bg-brand-600 p-10 text-center"> - <div className="text-4xl font-bold">{gameDisplayName}</div> - </div> - <div className="text-center text-black font-black text-10xl"> - {leftTimeSeconds} - </div> - </div> - ); + return ( + <div className="min-h-screen bg-gray-100 flex flex-col"> + <div className="text-white bg-brand-600 p-10 text-center"> + <div className="text-4xl font-bold">{gameDisplayName}</div> + </div> + <div className="text-center text-black font-black text-10xl"> + {leftTimeSeconds} + </div> + </div> + ); } diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx index 1341073..5c4e94f 100644 --- a/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx @@ -2,22 +2,22 @@ import type { PlayerProfile } from "../../types/PlayerProfile"; import PlayerNameAndIcon from "../PlayerNameAndIcon"; type Props = { - gameDisplayName: string; - playerProfile: PlayerProfile; + gameDisplayName: string; + playerProfile: PlayerProfile; }; export default function GolfPlayAppWaiting({ - gameDisplayName, - playerProfile, + gameDisplayName, + playerProfile, }: Props) { - return ( - <div className="min-h-screen bg-gray-100 flex flex-col font-bold text-center"> - <div className="text-white bg-brand-600 p-10"> - <div className="text-4xl">{gameDisplayName}</div> - </div> - <div className="grow grid mx-auto text-black"> - <PlayerNameAndIcon profile={playerProfile} /> - </div> - </div> - ); + return ( + <div className="min-h-screen bg-gray-100 flex flex-col font-bold text-center"> + <div className="text-white bg-brand-600 p-10"> + <div className="text-4xl">{gameDisplayName}</div> + </div> + <div className="grow grid mx-auto text-black"> + <PlayerNameAndIcon profile={playerProfile} /> + </div> + </div> + ); } diff --git a/frontend/app/components/GolfWatchApp.tsx b/frontend/app/components/GolfWatchApp.tsx index 41b5a01..b7feae7 100644 --- a/frontend/app/components/GolfWatchApp.tsx +++ b/frontend/app/components/GolfWatchApp.tsx @@ -5,12 +5,12 @@ import { useTimer } from "react-use-precision-timer"; import { ApiClientContext } from "../api/client"; import type { components } from "../api/schema"; import { - gameStateKindAtom, - rankingAtom, - setCurrentTimestampAtom, - setDurationSecondsAtom, - setGameStartedAtAtom, - setLatestGameStatesAtom, + gameStateKindAtom, + rankingAtom, + setCurrentTimestampAtom, + setDurationSecondsAtom, + setGameStartedAtAtom, + setLatestGameStatesAtom, } from "../states/watch"; import GolfWatchAppGaming1v1 from "./GolfWatchApps/GolfWatchAppGaming1v1"; import GolfWatchAppGamingMultiplayer from "./GolfWatchApps/GolfWatchAppGamingMultiplayer"; @@ -24,130 +24,130 @@ type LatestGameState = components["schemas"]["LatestGameState"]; type RankingEntry = components["schemas"]["RankingEntry"]; export type Props = { - game: Game; - initialGameStates: { [key: string]: LatestGameState }; - initialRanking: RankingEntry[]; + game: Game; + initialGameStates: { [key: string]: LatestGameState }; + initialRanking: RankingEntry[]; }; export default function GolfWatchApp({ - game, - initialGameStates, - initialRanking, + game, + initialGameStates, + initialRanking, }: Props) { - useHydrateAtoms([ - [rankingAtom, initialRanking], - [setDurationSecondsAtom, game.duration_seconds], - [setGameStartedAtAtom, game.started_at ?? null], - [setLatestGameStatesAtom, initialGameStates], - ]); + useHydrateAtoms([ + [rankingAtom, initialRanking], + [setDurationSecondsAtom, game.duration_seconds], + [setGameStartedAtAtom, game.started_at ?? null], + [setLatestGameStatesAtom, initialGameStates], + ]); - const apiClient = useContext(ApiClientContext)!; + const apiClient = useContext(ApiClientContext)!; - const gameStateKind = useAtomValue(gameStateKindAtom); - const setGameStartedAt = useSetAtom(setGameStartedAtAtom); - const setCurrentTimestamp = useSetAtom(setCurrentTimestampAtom); - const setLatestGameStates = useSetAtom(setLatestGameStatesAtom); - const setRanking = useSetAtom(rankingAtom); + const gameStateKind = useAtomValue(gameStateKindAtom); + const setGameStartedAt = useSetAtom(setGameStartedAtAtom); + const setCurrentTimestamp = useSetAtom(setCurrentTimestampAtom); + const setLatestGameStates = useSetAtom(setLatestGameStatesAtom); + const setRanking = useSetAtom(rankingAtom); - useTimer({ delay: 1000, startImmediately: true }, setCurrentTimestamp); + useTimer({ delay: 1000, startImmediately: true }, setCurrentTimestamp); - const playerA = game.main_players[0]; - const playerB = game.main_players[1]; + 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 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); + const [isDataPolling, setIsDataPolling] = useState(false); - useEffect(() => { - if (isDataPolling) { - return; - } - const timerId = setInterval(async () => { - if (isDataPolling) { - return; - } - setIsDataPolling(true); + useEffect(() => { + if (isDataPolling) { + return; + } + const timerId = setInterval(async () => { + if (isDataPolling) { + return; + } + setIsDataPolling(true); - try { - if (gameStateKind === "waiting") { - const { game: g } = await apiClient.getGame(game.game_id); - if (g.started_at != null) { - setGameStartedAt(g.started_at); - } - } else if (gameStateKind === "gaming") { - const { states } = await apiClient.getGameWatchLatestStates( - game.game_id, - ); - setLatestGameStates(states); - const { ranking } = await apiClient.getGameWatchRanking(game.game_id); - setRanking(ranking); - } - } catch (error) { - console.error(error); - } finally { - setIsDataPolling(false); - } - }, 1000); + try { + if (gameStateKind === "waiting") { + const { game: g } = await apiClient.getGame(game.game_id); + if (g.started_at != null) { + setGameStartedAt(g.started_at); + } + } else if (gameStateKind === "gaming") { + const { states } = await apiClient.getGameWatchLatestStates( + game.game_id, + ); + setLatestGameStates(states); + const { ranking } = await apiClient.getGameWatchRanking(game.game_id); + setRanking(ranking); + } + } catch (error) { + console.error(error); + } finally { + setIsDataPolling(false); + } + }, 1000); - return () => { - clearInterval(timerId); - }; - }, [ - isDataPolling, - apiClient, - game.game_id, - gameStateKind, - setGameStartedAt, - setLatestGameStates, - setRanking, - ]); + return () => { + clearInterval(timerId); + }; + }, [ + isDataPolling, + apiClient, + game.game_id, + gameStateKind, + setGameStartedAt, + setLatestGameStates, + setRanking, + ]); - if (gameStateKind === "loading") { - return <GolfWatchAppLoading />; - } else 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} - problemLanguage={game.problem.language} - sampleCode={game.problem.sample_code} - /> - ) : ( - <GolfWatchAppGamingMultiplayer - gameDisplayName={game.display_name} - problemTitle={game.problem.title} - problemDescription={game.problem.description} - problemLanguage={game.problem.language} - sampleCode={game.problem.sample_code} - /> - ); - } + if (gameStateKind === "loading") { + return <GolfWatchAppLoading />; + } else 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} + problemLanguage={game.problem.language} + sampleCode={game.problem.sample_code} + /> + ) : ( + <GolfWatchAppGamingMultiplayer + gameDisplayName={game.display_name} + problemTitle={game.problem.title} + problemDescription={game.problem.description} + problemLanguage={game.problem.language} + sampleCode={game.problem.sample_code} + /> + ); + } } diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming1v1.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming1v1.tsx index 3d2784e..f032607 100644 --- a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming1v1.tsx +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming1v1.tsx @@ -1,10 +1,10 @@ import { useAtomValue } from "jotai"; import { - calcCodeSize, - checkGameResultKind, - gameStateKindAtom, - gamingLeftTimeSecondsAtom, - latestGameStatesAtom, + calcCodeSize, + checkGameResultKind, + gameStateKindAtom, + gamingLeftTimeSecondsAtom, + latestGameStatesAtom, } from "../../states/watch"; import type { PlayerProfile } from "../../types/PlayerProfile"; import type { SupportedLanguage } from "../../types/SupportedLanguage"; @@ -21,140 +21,140 @@ import TitledColumn from "../TitledColumn"; import UserIcon from "../UserIcon"; type Props = { - gameDisplayName: string; - playerProfileA: PlayerProfile | null; - playerProfileB: PlayerProfile | null; - problemTitle: string; - problemDescription: string; - problemLanguage: SupportedLanguage; - sampleCode: string; + gameDisplayName: string; + playerProfileA: PlayerProfile | null; + playerProfileB: PlayerProfile | null; + problemTitle: string; + problemDescription: string; + problemLanguage: SupportedLanguage; + sampleCode: string; }; export default function GolfWatchAppGaming1v1({ - gameDisplayName, - playerProfileA, - playerProfileB, - problemTitle, - problemDescription, - problemLanguage, - sampleCode, + gameDisplayName, + playerProfileA, + playerProfileB, + problemTitle, + problemDescription, + problemLanguage, + sampleCode, }: Props) { - const gameStateKind = useAtomValue(gameStateKindAtom); - const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom)!; - const latestGameStates = useAtomValue(latestGameStatesAtom); + const gameStateKind = useAtomValue(gameStateKindAtom); + const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom)!; + const latestGameStates = useAtomValue(latestGameStatesAtom); - const stateA = - playerProfileA && (latestGameStates[`${playerProfileA.id}`] ?? null); - const codeA = stateA?.code ?? ""; - const scoreA = stateA?.score ?? null; - const statusA = stateA?.status ?? "none"; - const stateB = - playerProfileB && (latestGameStates[`${playerProfileB.id}`] ?? null); - const codeB = stateB?.code ?? ""; - const scoreB = stateB?.score ?? null; - const statusB = stateB?.status ?? "none"; + const stateA = + playerProfileA && (latestGameStates[`${playerProfileA.id}`] ?? null); + const codeA = stateA?.code ?? ""; + const scoreA = stateA?.score ?? null; + const statusA = stateA?.status ?? "none"; + const stateB = + playerProfileB && (latestGameStates[`${playerProfileB.id}`] ?? null); + const codeB = stateB?.code ?? ""; + const scoreB = stateB?.score ?? null; + const statusB = stateB?.status ?? "none"; - const codeSizeA = calcCodeSize(codeA, problemLanguage); - const codeSizeB = calcCodeSize(codeB, problemLanguage); + const codeSizeA = calcCodeSize(codeA, problemLanguage); + const codeSizeB = calcCodeSize(codeB, problemLanguage); - const gameResultKind = checkGameResultKind(gameStateKind, stateA, stateB); + const gameResultKind = checkGameResultKind(gameStateKind, stateA, stateB); - const topBg = gameResultKind - ? gameResultKind === "winA" - ? "bg-orange-400" - : gameResultKind === "winB" - ? "bg-purple-400" - : "bg-brand-600" - : "bg-brand-600"; + const topBg = gameResultKind + ? gameResultKind === "winA" + ? "bg-orange-400" + : gameResultKind === "winB" + ? "bg-purple-400" + : "bg-brand-600" + : "bg-brand-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 gap-4 justify-start md:justify-between items-center my-auto"> - <div className="flex gap-6 items-center"> - {playerProfileA?.iconPath && ( - <UserIcon - iconPath={playerProfileA.iconPath} - displayName={playerProfileA.displayName} - className="w-12 h-12 my-auto" - /> - )} - <div className="hidden md:block text-4xl"> - {playerProfileA?.displayName} - </div> - </div> - <div className="text-2xl md:text-6xl"> - <Score status={statusA} score={scoreA} /> - </div> - </div> - <div className="font-bold text-center"> - <div className="text-gray-100">{gameDisplayName}</div> - {gameResultKind ? ( - <div className="text-3xl"> - {gameResultKind === "winA" - ? `勝者 ${playerProfileA!.displayName}` - : gameResultKind === "winB" - ? `勝者 ${playerProfileB!.displayName}` - : "引き分け"} - </div> - ) : ( - <LeftTime sec={leftTimeSeconds} /> - )} - </div> - <div className="font-bold flex gap-4 justify-end md:justify-between items-center my-auto"> - <div className="text-2xl md:text-6xl"> - <Score status={statusB} score={scoreB} /> - </div> - <div className="flex gap-6 items-center text-end"> - <div className="hidden md:block text-4xl"> - {playerProfileB?.displayName} - </div> - {playerProfileB?.iconPath && ( - <UserIcon - iconPath={playerProfileB.iconPath} - displayName={playerProfileB.displayName} - className="w-12 h-12 my-auto" - /> - )} - </div> - </div> - </div> - <ScoreBar - scoreA={scoreA} - scoreB={scoreB} - bgA="bg-orange-400" - bgB="bg-purple-400" - /> - <ThreeColumnLayout> - <TitledColumn - title={<SubmitStatusLabel status={statusA} />} - className="order-2 md:order-1" - > - <FoldableBorderedContainerWithCaption - caption={`コードサイズ: ${codeSizeA}`} - > - <CodeBlock code={codeA} language={problemLanguage} /> - </FoldableBorderedContainerWithCaption> - </TitledColumn> - <TitledColumn title={problemTitle} className="order-1 md:order-2"> - <ProblemColumnContent - description={problemDescription} - language={problemLanguage} - sampleCode={sampleCode} - /> - <RankingTable problemLanguage={problemLanguage} /> - </TitledColumn> - <TitledColumn - title={<SubmitStatusLabel status={statusB} />} - className="order-3" - > - <FoldableBorderedContainerWithCaption - caption={`コードサイズ: ${codeSizeB}`} - > - <CodeBlock code={codeB} language={problemLanguage} /> - </FoldableBorderedContainerWithCaption> - </TitledColumn> - </ThreeColumnLayout> - </div> - ); + 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 gap-4 justify-start md:justify-between items-center my-auto"> + <div className="flex gap-6 items-center"> + {playerProfileA?.iconPath && ( + <UserIcon + iconPath={playerProfileA.iconPath} + displayName={playerProfileA.displayName} + className="w-12 h-12 my-auto" + /> + )} + <div className="hidden md:block text-4xl"> + {playerProfileA?.displayName} + </div> + </div> + <div className="text-2xl md:text-6xl"> + <Score status={statusA} score={scoreA} /> + </div> + </div> + <div className="font-bold text-center"> + <div className="text-gray-100">{gameDisplayName}</div> + {gameResultKind ? ( + <div className="text-3xl"> + {gameResultKind === "winA" + ? `勝者 ${playerProfileA!.displayName}` + : gameResultKind === "winB" + ? `勝者 ${playerProfileB!.displayName}` + : "引き分け"} + </div> + ) : ( + <LeftTime sec={leftTimeSeconds} /> + )} + </div> + <div className="font-bold flex gap-4 justify-end md:justify-between items-center my-auto"> + <div className="text-2xl md:text-6xl"> + <Score status={statusB} score={scoreB} /> + </div> + <div className="flex gap-6 items-center text-end"> + <div className="hidden md:block text-4xl"> + {playerProfileB?.displayName} + </div> + {playerProfileB?.iconPath && ( + <UserIcon + iconPath={playerProfileB.iconPath} + displayName={playerProfileB.displayName} + className="w-12 h-12 my-auto" + /> + )} + </div> + </div> + </div> + <ScoreBar + scoreA={scoreA} + scoreB={scoreB} + bgA="bg-orange-400" + bgB="bg-purple-400" + /> + <ThreeColumnLayout> + <TitledColumn + title={<SubmitStatusLabel status={statusA} />} + className="order-2 md:order-1" + > + <FoldableBorderedContainerWithCaption + caption={`コードサイズ: ${codeSizeA}`} + > + <CodeBlock code={codeA} language={problemLanguage} /> + </FoldableBorderedContainerWithCaption> + </TitledColumn> + <TitledColumn title={problemTitle} className="order-1 md:order-2"> + <ProblemColumnContent + description={problemDescription} + language={problemLanguage} + sampleCode={sampleCode} + /> + <RankingTable problemLanguage={problemLanguage} /> + </TitledColumn> + <TitledColumn + title={<SubmitStatusLabel status={statusB} />} + className="order-3" + > + <FoldableBorderedContainerWithCaption + caption={`コードサイズ: ${codeSizeB}`} + > + <CodeBlock code={codeB} language={problemLanguage} /> + </FoldableBorderedContainerWithCaption> + </TitledColumn> + </ThreeColumnLayout> + </div> + ); } diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppGamingMultiplayer.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppGamingMultiplayer.tsx index 320709b..ca2e16f 100644 --- a/frontend/app/components/GolfWatchApps/GolfWatchAppGamingMultiplayer.tsx +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppGamingMultiplayer.tsx @@ -8,43 +8,43 @@ import TitledColumn from "../TitledColumn"; import TwoColumnLayout from "../TwoColumnLayout"; type Props = { - gameDisplayName: string; - problemTitle: string; - problemDescription: string; - problemLanguage: SupportedLanguage; - sampleCode: string; + gameDisplayName: string; + problemTitle: string; + problemDescription: string; + problemLanguage: SupportedLanguage; + sampleCode: string; }; export default function GolfWatchAppGamingMultiplayer({ - gameDisplayName, - problemTitle, - problemDescription, - problemLanguage, - sampleCode, + gameDisplayName, + problemTitle, + problemDescription, + problemLanguage, + sampleCode, }: Props) { - const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom)!; + const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom)!; - return ( - <div className="min-h-screen bg-gray-100 flex flex-col"> - <div className="text-white bg-brand-600 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> - <LeftTime sec={leftTimeSeconds} /> - </div> - <div className="font-bold flex justify-between my-auto"></div> - </div> - <TwoColumnLayout> - <ProblemColumn - title={problemTitle} - description={problemDescription} - language={problemLanguage} - sampleCode={sampleCode} - /> - <TitledColumn title="順位表"> - <RankingTable problemLanguage={problemLanguage} /> - </TitledColumn> - </TwoColumnLayout> - </div> - ); + return ( + <div className="min-h-screen bg-gray-100 flex flex-col"> + <div className="text-white bg-brand-600 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> + <LeftTime sec={leftTimeSeconds} /> + </div> + <div className="font-bold flex justify-between my-auto"></div> + </div> + <TwoColumnLayout> + <ProblemColumn + title={problemTitle} + description={problemDescription} + language={problemLanguage} + sampleCode={sampleCode} + /> + <TitledColumn title="順位表"> + <RankingTable problemLanguage={problemLanguage} /> + </TitledColumn> + </TwoColumnLayout> + </div> + ); } diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppLoading.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppLoading.tsx index 7169d24..a1b3f77 100644 --- a/frontend/app/components/GolfWatchApps/GolfWatchAppLoading.tsx +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppLoading.tsx @@ -1,9 +1,9 @@ export default function GolfWatchAppLoading() { - 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> - ); + 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/GolfWatchAppStarting.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx index d9e004e..3083a07 100644 --- a/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx @@ -2,20 +2,20 @@ import { useAtomValue } from "jotai"; import { startingLeftTimeSecondsAtom } from "../../states/watch"; type Props = { - gameDisplayName: string; + gameDisplayName: string; }; export default function GolfWatchAppStarting({ gameDisplayName }: Props) { - const leftTimeSeconds = useAtomValue(startingLeftTimeSecondsAtom)!; + const leftTimeSeconds = useAtomValue(startingLeftTimeSecondsAtom)!; - return ( - <div className="min-h-screen bg-gray-100 flex flex-col"> - <div className="text-white bg-brand-600 p-10 text-center"> - <div className="text-4xl font-bold">{gameDisplayName}</div> - </div> - <div className="text-center text-black font-black text-10xl"> - {leftTimeSeconds} - </div> - </div> - ); + return ( + <div className="min-h-screen bg-gray-100 flex flex-col"> + <div className="text-white bg-brand-600 p-10 text-center"> + <div className="text-4xl font-bold">{gameDisplayName}</div> + </div> + <div className="text-center text-black font-black text-10xl"> + {leftTimeSeconds} + </div> + </div> + ); } diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting1v1.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting1v1.tsx index c7db3a7..48c3ab2 100644 --- a/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting1v1.tsx +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting1v1.tsx @@ -2,34 +2,34 @@ import type { PlayerProfile } from "../../types/PlayerProfile"; import PlayerNameAndIcon from "../PlayerNameAndIcon"; type Props = { - gameDisplayName: string; - playerProfileA: PlayerProfile | null; - playerProfileB: PlayerProfile | null; + gameDisplayName: string; + playerProfileA: PlayerProfile | null; + playerProfileB: PlayerProfile | null; }; export default function GolfWatchAppWaiting1v1({ - gameDisplayName, - playerProfileA, - playerProfileB, + 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-brand-600 p-10"> - <div className="text-4xl">{gameDisplayName}</div> - </div> - <div className="grow grid grid-cols-3 gap-10 mx-auto text-black"> - {playerProfileA ? ( - <PlayerNameAndIcon profile={playerProfileA} /> - ) : ( - <div></div> - )} - <div className="text-8xl my-auto">vs.</div> - {playerProfileB ? ( - <PlayerNameAndIcon profile={playerProfileB} /> - ) : ( - <div></div> - )} - </div> - </div> - ); + return ( + <div className="min-h-screen bg-gray-100 flex flex-col font-bold text-center"> + <div className="text-white bg-brand-600 p-10"> + <div className="text-4xl">{gameDisplayName}</div> + </div> + <div className="grow grid grid-cols-3 gap-10 mx-auto text-black"> + {playerProfileA ? ( + <PlayerNameAndIcon profile={playerProfileA} /> + ) : ( + <div></div> + )} + <div className="text-8xl my-auto">vs.</div> + {playerProfileB ? ( + <PlayerNameAndIcon profile={playerProfileB} /> + ) : ( + <div></div> + )} + </div> + </div> + ); } diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppWaitingMultiplayer.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppWaitingMultiplayer.tsx index 72757f3..de742b4 100644 --- a/frontend/app/components/GolfWatchApps/GolfWatchAppWaitingMultiplayer.tsx +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppWaitingMultiplayer.tsx @@ -1,15 +1,15 @@ type Props = { - gameDisplayName: string; + gameDisplayName: string; }; export default function GolfWatchAppWaitingMultiplayer({ - gameDisplayName, + gameDisplayName, }: Props) { - return ( - <div className="min-h-screen bg-gray-100 flex flex-col font-bold text-center"> - <div className="text-white bg-brand-600 p-10"> - <div className="text-4xl">{gameDisplayName}</div> - </div> - </div> - ); + return ( + <div className="min-h-screen bg-gray-100 flex flex-col font-bold text-center"> + <div className="text-white bg-brand-600 p-10"> + <div className="text-4xl">{gameDisplayName}</div> + </div> + </div> + ); } diff --git a/frontend/app/components/InputText.test.tsx b/frontend/app/components/InputText.test.tsx index 6fbdd27..8acf6f2 100644 --- a/frontend/app/components/InputText.test.tsx +++ b/frontend/app/components/InputText.test.tsx @@ -6,31 +6,31 @@ import { afterEach, describe, expect, test } from "vitest"; import InputText from "./InputText"; afterEach(() => { - cleanup(); + cleanup(); }); describe("InputText", () => { - test("renders an input element", () => { - render(<InputText data-testid="input" />); - const input = screen.getByTestId("input"); - expect(input.tagName).toBe("INPUT"); - }); + test("renders an input element", () => { + render(<InputText data-testid="input" />); + const input = screen.getByTestId("input"); + expect(input.tagName).toBe("INPUT"); + }); - test("passes placeholder prop", () => { - render(<InputText placeholder="Enter text" data-testid="input" />); - const input = screen.getByTestId("input") as HTMLInputElement; - expect(input.placeholder).toBe("Enter text"); - }); + test("passes placeholder prop", () => { + render(<InputText placeholder="Enter text" data-testid="input" />); + const input = screen.getByTestId("input") as HTMLInputElement; + expect(input.placeholder).toBe("Enter text"); + }); - test("passes type prop", () => { - render(<InputText type="password" data-testid="input" />); - const input = screen.getByTestId("input") as HTMLInputElement; - expect(input.type).toBe("password"); - }); + test("passes type prop", () => { + render(<InputText type="password" data-testid="input" />); + const input = screen.getByTestId("input") as HTMLInputElement; + expect(input.type).toBe("password"); + }); - test("has border styling", () => { - render(<InputText data-testid="input" />); - const input = screen.getByTestId("input"); - expect(input.className).toContain("border-brand-600"); - }); + test("has border styling", () => { + render(<InputText data-testid="input" />); + const input = screen.getByTestId("input"); + expect(input.className).toContain("border-brand-600"); + }); }); diff --git a/frontend/app/components/InputText.tsx b/frontend/app/components/InputText.tsx index 9b57a74..76ca860 100644 --- a/frontend/app/components/InputText.tsx +++ b/frontend/app/components/InputText.tsx @@ -3,10 +3,10 @@ import React from "react"; type InputProps = React.InputHTMLAttributes<HTMLInputElement>; export default function InputText(props: InputProps) { - return ( - <input - {...props} - className="p-2 block w-full border border-brand-600 rounded-md transition duration-300 focus:ring-3 focus:ring-brand-400 focus:outline-hidden" - /> - ); + return ( + <input + {...props} + className="p-2 block w-full border border-brand-600 rounded-md transition duration-300 focus:ring-3 focus:ring-brand-400 focus:outline-hidden" + /> + ); } diff --git a/frontend/app/components/NavigateLink.tsx b/frontend/app/components/NavigateLink.tsx index 8741121..712c5ce 100644 --- a/frontend/app/components/NavigateLink.tsx +++ b/frontend/app/components/NavigateLink.tsx @@ -1,18 +1,18 @@ import { Link } from "wouter"; export default function NavigateLink({ - to, - children, + to, + children, }: { - to: string; - children: React.ReactNode; + to: string; + children: React.ReactNode; }) { - return ( - <Link - to={to} - className="text-lg text-white bg-brand-600 px-4 py-2 border-2 border-brand-50 rounded-sm transition duration-300 hover:bg-brand-500 focus:ring-3 focus:ring-brand-400 focus:outline-hidden" - > - {children} - </Link> - ); + return ( + <Link + to={to} + className="text-lg text-white bg-brand-600 px-4 py-2 border-2 border-brand-50 rounded-sm transition duration-300 hover:bg-brand-500 focus:ring-3 focus:ring-brand-400 focus:outline-hidden" + > + {children} + </Link> + ); } diff --git a/frontend/app/components/PlayerNameAndIcon.test.tsx b/frontend/app/components/PlayerNameAndIcon.test.tsx index 5a9dd49..e7b851c 100644 --- a/frontend/app/components/PlayerNameAndIcon.test.tsx +++ b/frontend/app/components/PlayerNameAndIcon.test.tsx @@ -6,34 +6,34 @@ import { afterEach, describe, expect, test } from "vitest"; import PlayerNameAndIcon from "./PlayerNameAndIcon"; afterEach(() => { - cleanup(); + cleanup(); }); describe("PlayerNameAndIcon", () => { - test("renders display name", () => { - render( - <PlayerNameAndIcon - profile={{ id: 1, displayName: "Alice", iconPath: null }} - />, - ); - expect(screen.getByText("Alice")).toBeDefined(); - }); + test("renders display name", () => { + render( + <PlayerNameAndIcon + profile={{ id: 1, displayName: "Alice", iconPath: null }} + />, + ); + expect(screen.getByText("Alice")).toBeDefined(); + }); - test("does not render icon when iconPath is null", () => { - render( - <PlayerNameAndIcon - profile={{ id: 1, displayName: "Bob", iconPath: null }} - />, - ); - expect(screen.queryByRole("img")).toBeNull(); - }); + test("does not render icon when iconPath is null", () => { + render( + <PlayerNameAndIcon + profile={{ id: 1, displayName: "Bob", iconPath: null }} + />, + ); + expect(screen.queryByRole("img")).toBeNull(); + }); - test("renders icon when iconPath is provided", () => { - render( - <PlayerNameAndIcon - profile={{ id: 1, displayName: "Carol", iconPath: "icons/carol.png" }} - />, - ); - expect(screen.getByAltText("Carol のアイコン")).toBeDefined(); - }); + test("renders icon when iconPath is provided", () => { + render( + <PlayerNameAndIcon + profile={{ id: 1, displayName: "Carol", iconPath: "icons/carol.png" }} + />, + ); + expect(screen.getByAltText("Carol のアイコン")).toBeDefined(); + }); }); diff --git a/frontend/app/components/PlayerNameAndIcon.tsx b/frontend/app/components/PlayerNameAndIcon.tsx index 92b757d..56423c1 100644 --- a/frontend/app/components/PlayerNameAndIcon.tsx +++ b/frontend/app/components/PlayerNameAndIcon.tsx @@ -2,20 +2,20 @@ import { PlayerProfile } from "../types/PlayerProfile"; import UserIcon from "./UserIcon"; type Props = { - profile: PlayerProfile; + profile: PlayerProfile; }; export default function PlayerNameAndIcon({ profile }: Props) { - return ( - <div className="flex flex-col gap-6 my-auto items-center"> - <div className="text-6xl">{profile.displayName}</div> - {profile.iconPath && ( - <UserIcon - iconPath={profile.iconPath} - displayName={profile.displayName} - className="w-48 h-48" - /> - )} - </div> - ); + return ( + <div className="flex flex-col gap-6 my-auto items-center"> + <div className="text-6xl">{profile.displayName}</div> + {profile.iconPath && ( + <UserIcon + iconPath={profile.iconPath} + displayName={profile.displayName} + className="w-48 h-48" + /> + )} + </div> + ); } diff --git a/frontend/app/components/ProtectedRoute.tsx b/frontend/app/components/ProtectedRoute.tsx index b943696..5f564a0 100644 --- a/frontend/app/components/ProtectedRoute.tsx +++ b/frontend/app/components/ProtectedRoute.tsx @@ -2,19 +2,19 @@ import { Redirect } from "wouter"; import { useAuth } from "../hooks/useAuth"; export default function ProtectedRoute({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - const { isLoggedIn, isLoading } = useAuth(); + const { isLoggedIn, isLoading } = useAuth(); - if (isLoading) { - return null; - } + if (isLoading) { + return null; + } - if (!isLoggedIn) { - return <Redirect to="/login" />; - } + if (!isLoggedIn) { + return <Redirect to="/login" />; + } - return <>{children}</>; + return <>{children}</>; } diff --git a/frontend/app/components/PublicOnlyRoute.tsx b/frontend/app/components/PublicOnlyRoute.tsx index 7b3ef9d..e28f5ee 100644 --- a/frontend/app/components/PublicOnlyRoute.tsx +++ b/frontend/app/components/PublicOnlyRoute.tsx @@ -2,19 +2,19 @@ import { Redirect } from "wouter"; import { useAuth } from "../hooks/useAuth"; export default function PublicOnlyRoute({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - const { isLoggedIn, isLoading } = useAuth(); + const { isLoggedIn, isLoading } = useAuth(); - if (isLoading) { - return null; - } + if (isLoading) { + return null; + } - if (isLoggedIn) { - return <Redirect to="/dashboard" />; - } + if (isLoggedIn) { + return <Redirect to="/dashboard" />; + } - return <>{children}</>; + return <>{children}</>; } diff --git a/frontend/app/components/SubmitButton.test.tsx b/frontend/app/components/SubmitButton.test.tsx index 9a8085e..06cf5f5 100644 --- a/frontend/app/components/SubmitButton.test.tsx +++ b/frontend/app/components/SubmitButton.test.tsx @@ -6,36 +6,36 @@ import { afterEach, describe, expect, test } from "vitest"; import SubmitButton from "./SubmitButton"; afterEach(() => { - cleanup(); + cleanup(); }); describe("SubmitButton", () => { - test("renders children text", () => { - render(<SubmitButton>Submit</SubmitButton>); - expect(screen.getByText("Submit")).toBeDefined(); - }); + test("renders children text", () => { + render(<SubmitButton>Submit</SubmitButton>); + expect(screen.getByText("Submit")).toBeDefined(); + }); - test("renders as a button element", () => { - render(<SubmitButton>Click</SubmitButton>); - const button = screen.getByText("Click"); - expect(button.tagName).toBe("BUTTON"); - }); + test("renders as a button element", () => { + render(<SubmitButton>Click</SubmitButton>); + const button = screen.getByText("Click"); + expect(button.tagName).toBe("BUTTON"); + }); - test("can be disabled", () => { - render(<SubmitButton disabled>Submit</SubmitButton>); - const button = screen.getByText("Submit") as HTMLButtonElement; - expect(button.disabled).toBe(true); - }); + test("can be disabled", () => { + render(<SubmitButton disabled>Submit</SubmitButton>); + const button = screen.getByText("Submit") as HTMLButtonElement; + expect(button.disabled).toBe(true); + }); - test("is not disabled by default", () => { - render(<SubmitButton>Submit</SubmitButton>); - const button = screen.getByText("Submit") as HTMLButtonElement; - expect(button.disabled).toBe(false); - }); + test("is not disabled by default", () => { + render(<SubmitButton>Submit</SubmitButton>); + const button = screen.getByText("Submit") as HTMLButtonElement; + expect(button.disabled).toBe(false); + }); - test("has brand-600 background styling", () => { - render(<SubmitButton>Submit</SubmitButton>); - const button = screen.getByText("Submit"); - expect(button.className).toContain("bg-brand-600"); - }); + test("has brand-600 background styling", () => { + render(<SubmitButton>Submit</SubmitButton>); + const button = screen.getByText("Submit"); + expect(button.className).toContain("bg-brand-600"); + }); }); diff --git a/frontend/app/components/SubmitButton.tsx b/frontend/app/components/SubmitButton.tsx index 916c20f..acb66f6 100644 --- a/frontend/app/components/SubmitButton.tsx +++ b/frontend/app/components/SubmitButton.tsx @@ -3,10 +3,10 @@ import React from "react"; type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>; export default function SubmitButton(props: ButtonProps) { - return ( - <button - {...props} - className="text-lg text-white px-4 py-2 bg-brand-600 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-sm transition duration-300 hover:bg-brand-500 focus:ring-3 focus:ring-brand-400 focus:outline-hidden" - /> - ); + return ( + <button + {...props} + className="text-lg text-white px-4 py-2 bg-brand-600 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-sm transition duration-300 hover:bg-brand-500 focus:ring-3 focus:ring-brand-400 focus:outline-hidden" + /> + ); } diff --git a/frontend/app/components/SubmitStatusLabel.test.tsx b/frontend/app/components/SubmitStatusLabel.test.tsx index cdedf9e..08ba636 100644 --- a/frontend/app/components/SubmitStatusLabel.test.tsx +++ b/frontend/app/components/SubmitStatusLabel.test.tsx @@ -6,47 +6,47 @@ import { afterEach, describe, expect, test } from "vitest"; import SubmitStatusLabel from "./SubmitStatusLabel"; afterEach(() => { - cleanup(); + cleanup(); }); describe("SubmitStatusLabel", () => { - test("renders '提出待ち' for none status", () => { - render(<SubmitStatusLabel status="none" />); - expect(screen.getByText("提出待ち")).toBeDefined(); - }); + test("renders '提出待ち' for none status", () => { + render(<SubmitStatusLabel status="none" />); + expect(screen.getByText("提出待ち")).toBeDefined(); + }); - test("renders '実行中...' for running status", () => { - render(<SubmitStatusLabel status="running" />); - expect(screen.getByText("実行中...")).toBeDefined(); - }); + test("renders '実行中...' for running status", () => { + render(<SubmitStatusLabel status="running" />); + expect(screen.getByText("実行中...")).toBeDefined(); + }); - test("renders '成功' for success status", () => { - render(<SubmitStatusLabel status="success" />); - expect(screen.getByText("成功")).toBeDefined(); - }); + test("renders '成功' for success status", () => { + render(<SubmitStatusLabel status="success" />); + expect(screen.getByText("成功")).toBeDefined(); + }); - test("renders 'テスト失敗' for wrong_answer status", () => { - render(<SubmitStatusLabel status="wrong_answer" />); - expect(screen.getByText("テスト失敗")).toBeDefined(); - }); + test("renders 'テスト失敗' for wrong_answer status", () => { + render(<SubmitStatusLabel status="wrong_answer" />); + expect(screen.getByText("テスト失敗")).toBeDefined(); + }); - test("renders '時間切れ' for timeout status", () => { - render(<SubmitStatusLabel status="timeout" />); - expect(screen.getByText("時間切れ")).toBeDefined(); - }); + test("renders '時間切れ' for timeout status", () => { + render(<SubmitStatusLabel status="timeout" />); + expect(screen.getByText("時間切れ")).toBeDefined(); + }); - test("renders 'コンパイルエラー' for compile_error status", () => { - render(<SubmitStatusLabel status="compile_error" />); - expect(screen.getByText("コンパイルエラー")).toBeDefined(); - }); + test("renders 'コンパイルエラー' for compile_error status", () => { + render(<SubmitStatusLabel status="compile_error" />); + expect(screen.getByText("コンパイルエラー")).toBeDefined(); + }); - test("renders '実行時エラー' for runtime_error status", () => { - render(<SubmitStatusLabel status="runtime_error" />); - expect(screen.getByText("実行時エラー")).toBeDefined(); - }); + test("renders '実行時エラー' for runtime_error status", () => { + render(<SubmitStatusLabel status="runtime_error" />); + expect(screen.getByText("実行時エラー")).toBeDefined(); + }); - test("renders '!内部エラー!' for internal_error status", () => { - render(<SubmitStatusLabel status="internal_error" />); - expect(screen.getByText("!内部エラー!")).toBeDefined(); - }); + test("renders '!内部エラー!' for internal_error status", () => { + render(<SubmitStatusLabel status="internal_error" />); + expect(screen.getByText("!内部エラー!")).toBeDefined(); + }); }); diff --git a/frontend/app/components/SubmitStatusLabel.tsx b/frontend/app/components/SubmitStatusLabel.tsx index b3091b0..e511db2 100644 --- a/frontend/app/components/SubmitStatusLabel.tsx +++ b/frontend/app/components/SubmitStatusLabel.tsx @@ -1,26 +1,26 @@ import type { components } from "../api/schema"; type Props = { - status: components["schemas"]["ExecutionStatus"]; + status: components["schemas"]["ExecutionStatus"]; }; export default function SubmitStatusLabel({ status }: Props) { - switch (status) { - case "none": - return "提出待ち"; - case "running": - return "実行中..."; - case "success": - return "成功"; - case "wrong_answer": - return "テスト失敗"; - case "timeout": - return "時間切れ"; - case "compile_error": - return "コンパイルエラー"; - case "runtime_error": - return "実行時エラー"; - case "internal_error": - return "!内部エラー!"; - } + switch (status) { + case "none": + return "提出待ち"; + case "running": + return "実行中..."; + case "success": + return "成功"; + case "wrong_answer": + return "テスト失敗"; + case "timeout": + return "時間切れ"; + case "compile_error": + return "コンパイルエラー"; + case "runtime_error": + return "実行時エラー"; + case "internal_error": + return "!内部エラー!"; + } } diff --git a/frontend/app/components/ThreeColumnLayout.tsx b/frontend/app/components/ThreeColumnLayout.tsx index d2a5ba5..694b9cb 100644 --- a/frontend/app/components/ThreeColumnLayout.tsx +++ b/frontend/app/components/ThreeColumnLayout.tsx @@ -1,13 +1,13 @@ import React from "react"; type Props = { - children: React.ReactNode; + children: React.ReactNode; }; export default function ThreeColumnLayout({ children }: Props) { - return ( - <div className="grow grid grid-cols-1 md:grid-cols-3 md:divide-x divide-gray-300"> - {children} - </div> - ); + return ( + <div className="grow grid grid-cols-1 md:grid-cols-3 md:divide-x divide-gray-300"> + {children} + </div> + ); } diff --git a/frontend/app/components/TitledColumn.tsx b/frontend/app/components/TitledColumn.tsx index a26271b..ac43091 100644 --- a/frontend/app/components/TitledColumn.tsx +++ b/frontend/app/components/TitledColumn.tsx @@ -1,16 +1,16 @@ import React from "react"; type Props = { - children: React.ReactNode; - title: React.ReactNode; - className?: string; + children: React.ReactNode; + title: React.ReactNode; + className?: string; }; export default function TitledColumn({ children, title, className }: Props) { - return ( - <div className={`p-4 flex flex-col gap-4 ${className}`}> - <div className="text-center text-xl font-bold">{title}</div> - {children} - </div> - ); + return ( + <div className={`p-4 flex flex-col gap-4 ${className}`}> + <div className="text-center text-xl font-bold">{title}</div> + {children} + </div> + ); } diff --git a/frontend/app/components/TwoColumnLayout.tsx b/frontend/app/components/TwoColumnLayout.tsx index 68eab32..83640ec 100644 --- a/frontend/app/components/TwoColumnLayout.tsx +++ b/frontend/app/components/TwoColumnLayout.tsx @@ -1,13 +1,13 @@ import React from "react"; type Props = { - children: React.ReactNode; + children: React.ReactNode; }; export default function TwoColumnLayout({ children }: Props) { - return ( - <div className="grow grid grid-cols-1 md:grid-cols-2 md:divide-x divide-gray-300"> - {children} - </div> - ); + return ( + <div className="grow grid grid-cols-1 md:grid-cols-2 md:divide-x divide-gray-300"> + {children} + </div> + ); } diff --git a/frontend/app/components/UserIcon.test.tsx b/frontend/app/components/UserIcon.test.tsx index 964aae1..5311234 100644 --- a/frontend/app/components/UserIcon.test.tsx +++ b/frontend/app/components/UserIcon.test.tsx @@ -6,57 +6,57 @@ import { afterEach, describe, expect, test } from "vitest"; import UserIcon from "./UserIcon"; afterEach(() => { - cleanup(); + cleanup(); }); describe("UserIcon", () => { - test("renders an img element", () => { - render( - <UserIcon - iconPath="icons/test.png" - displayName="TestUser" - className="w-16 h-16" - />, - ); - const img = screen.getByAltText("TestUser のアイコン"); - expect(img.tagName).toBe("IMG"); - }); + test("renders an img element", () => { + render( + <UserIcon + iconPath="icons/test.png" + displayName="TestUser" + className="w-16 h-16" + />, + ); + const img = screen.getByAltText("TestUser のアイコン"); + expect(img.tagName).toBe("IMG"); + }); - test("sets alt text with display name", () => { - render( - <UserIcon - iconPath="icons/test.png" - displayName="Alice" - className="w-16 h-16" - />, - ); - expect(screen.getByAltText("Alice のアイコン")).toBeDefined(); - }); + test("sets alt text with display name", () => { + render( + <UserIcon + iconPath="icons/test.png" + displayName="Alice" + className="w-16 h-16" + />, + ); + expect(screen.getByAltText("Alice のアイコン")).toBeDefined(); + }); - test("applies rounded-full and border classes", () => { - render( - <UserIcon - iconPath="icons/test.png" - displayName="Bob" - className="w-16 h-16" - />, - ); - const img = screen.getByAltText("Bob のアイコン"); - expect(img.className).toContain("rounded-full"); - expect(img.className).toContain("border-4"); - expect(img.className).toContain("border-white"); - }); + test("applies rounded-full and border classes", () => { + render( + <UserIcon + iconPath="icons/test.png" + displayName="Bob" + className="w-16 h-16" + />, + ); + const img = screen.getByAltText("Bob のアイコン"); + expect(img.className).toContain("rounded-full"); + expect(img.className).toContain("border-4"); + expect(img.className).toContain("border-white"); + }); - test("applies custom className", () => { - render( - <UserIcon - iconPath="icons/test.png" - displayName="Bob" - className="w-48 h-48" - />, - ); - const img = screen.getByAltText("Bob のアイコン"); - expect(img.className).toContain("w-48"); - expect(img.className).toContain("h-48"); - }); + test("applies custom className", () => { + render( + <UserIcon + iconPath="icons/test.png" + displayName="Bob" + className="w-48 h-48" + />, + ); + const img = screen.getByAltText("Bob のアイコン"); + expect(img.className).toContain("w-48"); + expect(img.className).toContain("h-48"); + }); }); diff --git a/frontend/app/components/UserIcon.tsx b/frontend/app/components/UserIcon.tsx index b2750dd..9c57c42 100644 --- a/frontend/app/components/UserIcon.tsx +++ b/frontend/app/components/UserIcon.tsx @@ -1,21 +1,21 @@ import { BASE_PATH } from "../config"; type Props = { - iconPath: string; - displayName: string; - className: string; + iconPath: string; + displayName: string; + className: string; }; export default function UserIcon({ iconPath, displayName, className }: Props) { - return ( - <img - src={ - process.env.NODE_ENV === "development" - ? `http://localhost:8007${BASE_PATH}${iconPath}` - : `${BASE_PATH}${iconPath}` - } - alt={`${displayName} のアイコン`} - className={`rounded-full border-4 border-white ${className}`} - /> - ); + return ( + <img + src={ + process.env.NODE_ENV === "development" + ? `http://localhost:8007${BASE_PATH}${iconPath}` + : `${BASE_PATH}${iconPath}` + } + alt={`${displayName} のアイコン`} + className={`rounded-full border-4 border-white ${className}`} + /> + ); } |
