숫자 괄호 학습 준비의 play 폴더 위치 변경

This commit is contained in:
2026-05-30 17:16:53 +09:00
parent f3d051cdd1
commit e0d8fc19d5
5 changed files with 17 additions and 13 deletions
@@ -124,7 +124,7 @@ export default function NumberParenthesesClient() {
<div>
<Link
ref={startLinkRef}
href={`/play?count=${selectedProblemCount}`}
href={`/number-parentheses/play?count=${selectedProblemCount}`}
onClick={() => initSession(selectedProblemCount)}
className="inline-flex h-12 items-center justify-center rounded-md bg-emerald-700 px-6 text-base font-semibold text-white transition hover:bg-emerald-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
>
@@ -0,0 +1,243 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import { flushSync } from "react-dom";
import AnswerForm from "@/components/game/AnswerForm";
import type {
ActiveAnswerInput,
AnswerFormHandle,
} from "@/components/game/AnswerForm";
import FeedbackPanel from "@/components/game/FeedbackPanel";
import ScoreSummary from "@/components/game/ScoreSummary";
import StageIndicator from "@/components/game/StageIndicator";
import { DESKTOP_INPUT_DEVICE_QUERY } from "@/lib/input-device";
import KatexRenderer from "@/components/math/KatexRenderer";
import { useGameStore } from "@/store/gameStore";
import type { ProblemCount } from "@/store/gameStore";
const REAL_NUMBER_PATTERN = /^-?\d+(?:\.\d+)?$/;
function parseAnswer(value: string) {
const trimmed = value.trim();
if (!REAL_NUMBER_PATTERN.test(trimmed)) return null;
return Number(trimmed);
}
interface NumberPlayClientProps {
problemCount: ProblemCount;
}
export default function NumberPlayClient({
problemCount,
}: NumberPlayClientProps) {
const session = useGameStore((state) => state.session);
const selectedProblemCount = useGameStore(
(state) => state.selectedProblemCount,
);
const setProblemCount = useGameStore((state) => state.setProblemCount);
const initSession = useGameStore((state) => state.initSession);
const submitAnswer = useGameStore((state) => state.submitAnswer);
const nextProblem = useGameStore((state) => state.nextProblem);
const resetSession = useGameStore((state) => state.resetSession);
const problemCardRef = useRef<HTMLDivElement>(null);
const answerFormRef = useRef<AnswerFormHandle>(null);
const [answerA, setAnswerA] = useState("");
const [answerB, setAnswerB] = useState("");
const [activeInput, setActiveInput] = useState<ActiveAnswerInput>(0);
const [inputError, setInputError] = useState("");
const sessionProblemCount = session?.problems.length ?? null;
const currentProblem = session?.problems[session.currentIndex] ?? null;
const currentResult = session?.results[session.currentIndex] ?? null;
const isComplete = Boolean(
session && session.currentIndex >= session.problems.length,
);
const sessionNeedsInit = sessionProblemCount !== problemCount;
const correctCount =
session?.results.filter((result) => result?.correct).length ?? 0;
const totalCount = session?.problems.length ?? 0;
useEffect(() => {
if (selectedProblemCount !== problemCount) {
setProblemCount(problemCount);
}
if (sessionNeedsInit) {
initSession(problemCount);
}
}, [
initSession,
problemCount,
selectedProblemCount,
sessionProblemCount,
sessionNeedsInit,
setProblemCount,
]);
useEffect(() => {
setAnswerA("");
setAnswerB("");
setActiveInput(0);
setInputError("");
}, [currentProblem?.key]);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (currentResult || !currentProblem) return;
const parsedA = parseAnswer(answerA);
const parsedB = parseAnswer(answerB);
if (parsedA === null || parsedB === null) {
setInputError("두 칸 모두 실수로 입력하세요.");
return;
}
setInputError("");
submitAnswer({ a: parsedA, b: parsedB });
}
function handleNextProblem() {
flushSync(() => {
nextProblem();
});
answerFormRef.current?.focusInput();
}
const scrollKatexToViewportTop = useCallback(() => {
if (window.matchMedia(DESKTOP_INPUT_DEVICE_QUERY).matches) return;
function alignKatex() {
const katexElement =
problemCardRef.current?.querySelector<HTMLElement>("span.katex");
if (!katexElement) return;
const rect = katexElement.getBoundingClientRect();
const scrollElement =
document.scrollingElement ?? document.documentElement;
const elementDocumentTop = window.scrollY + rect.top;
const maxScrollTop = Math.max(
0,
scrollElement.scrollHeight - window.innerHeight,
);
const scrollTop = Math.min(
maxScrollTop,
Math.max(0, elementDocumentTop),
);
if (Math.abs(window.scrollY - scrollTop) < 4) return;
scrollElement.scrollTop = scrollTop;
document.body.scrollTop = scrollTop;
window.scrollTo(0, scrollTop);
}
window.requestAnimationFrame(() => {
window.requestAnimationFrame(alignKatex);
});
}, []);
if (!session || sessionNeedsInit) {
return (
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
<section className="mx-auto w-full max-w-3xl">
<p className="text-lg font-semibold text-slate-700">
.
</p>
</section>
</main>
);
}
if (isComplete) {
return (
<ScoreSummary
correctCount={correctCount}
totalCount={totalCount}
problems={session.problems}
results={session.results}
onRestart={resetSession}
/>
);
}
if (!currentProblem) {
return (
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
<section className="mx-auto w-full max-w-3xl">
<p className="text-lg font-semibold text-slate-700">
.
</p>
</section>
</main>
);
}
const submitted = currentResult !== null;
const isLastProblem = session.currentIndex === session.problems.length - 1;
return (
<main className="min-h-screen bg-slate-50 px-6 py-10 text-slate-950">
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-2">
<p className="text-sm font-semibold text-emerald-700">
</p>
<h1 className="text-3xl font-bold tracking-normal sm:text-4xl">
</h1>
</div>
<StageIndicator
currentIndex={session.currentIndex}
results={session.results}
/>
</div>
<div
ref={problemCardRef}
className="rounded-md bg-white px-6 pt-6 pb-2 shadow-sm ring-1 ring-slate-200 sm:p-8"
>
<p className="mb-4 text-sm font-semibold text-slate-500">
.
</p>
<div className="text-3xl font-semibold text-slate-950 sm:text-4xl">
<KatexRenderer latex={currentProblem.latexBefore} displayMode />
</div>
</div>
{!submitted ? (
<AnswerForm
ref={answerFormRef}
answerA={answerA}
answerB={answerB}
activeInput={activeInput}
error={inputError}
onActiveInputChange={setActiveInput}
onAnswerAChange={setAnswerA}
onAnswerBChange={setAnswerB}
onInputFocus={scrollKatexToViewportTop}
onSubmit={handleSubmit}
/>
) : null}
{currentResult ? (
<FeedbackPanel
result={currentResult}
problem={currentProblem}
isLastProblem={isLastProblem}
onNext={handleNextProblem}
/>
) : null}
</section>
</main>
);
}
@@ -0,0 +1,22 @@
import NumberPlayClient from "./NumberPlayClient";
import type { ProblemCount } from "@/store/gameStore";
interface NumberPlayPageProps {
searchParams?: {
count?: string | string[];
};
}
function parseProblemCount(count?: string | string[]): ProblemCount {
if (Array.isArray(count)) {
return parseProblemCount(count[0]);
}
return count === "5" ? 5 : 10;
}
export default function NumberPlayPage({ searchParams }: NumberPlayPageProps) {
return (
<NumberPlayClient problemCount={parseProblemCount(searchParams?.count)} />
);
}