Compare commits

...

35 Commits

Author SHA1 Message Date
jisangs de0a92d10f 리펙토링: 코드 최적화 2026-05-31 16:21:55 +09:00
jisangs 6300fe1082 풀이하고 있는 내용 색상 강조 2026-05-31 16:13:14 +09:00
jisangs ac3c939051 괄호 안의 항을 2, 3, 4 항으로 설정하는 기능 추가 2026-05-31 13:47:30 +09:00
jisangs e148a957ea 리펙토링: 문제 준비 화면 공통 요소 묶기 2026-05-31 13:14:33 +09:00
jisangs a73a9afcc3 리펙토링: 코드 검토 2026-05-30 18:44:51 +09:00
jisangs 33e065301f 문제 풀이 화면의 입력 항 진행 상태 표시 방법 개선 2026-05-30 18:39:22 +09:00
jisangs 282e4d64cf 문제 풀이 화면에 [포기] 버튼 버튼 추가 2026-05-30 18:07:15 +09:00
jisangs 503bad859e 문제 풀이 화면의 step progress bar 수정 2026-05-30 17:45:03 +09:00
jisangs 2bbe03b1aa 준비 화면에 뒤로 가기 버튼 추가 2026-05-30 17:27:56 +09:00
jisangs e0d8fc19d5 숫자 괄호 학습 준비의 play 폴더 위치 변경 2026-05-30 17:16:53 +09:00
jisangs f3d051cdd1 미지수 괄호 문제 풀이 추가 2026-05-29 23:47:10 +09:00
jisangs 335898b721 학습 내용 선택 화면 추가 2026-05-29 22:48:23 +09:00
jisangs 4d6fb49cba 리펙토링: 코드 최적화 2026-05-27 18:15:27 +09:00
jisangs 326da377c9 모바일 환경에서만 실행되는 가상 키보드 추가 2026-05-27 18:09:59 +09:00
jisangs 8efe618a31 풀이가 끝나고 나서, 첫 화면으로 돌아온 뒤 다시 시작할 때 결과화면이 나오는 오류 수정 2026-05-26 19:02:10 +09:00
jisangs 98dd5d319c 리펙토링: 코드 최적화, 출력 양식 변경 2026-05-26 18:39:50 +09:00
jisangs 2427b458dc 결과창에 정답, 오답 내용 출력 2026-05-26 18:25:57 +09:00
jisangs 51a96db269 답 입력 완료시 정답/오답 내용 출력 2026-05-26 18:18:43 +09:00
jisangs 2ebb8dbc32 리펙토링: 코드 최적화, AGENTS.md, README.md 내용 반영 2026-05-26 17:34:40 +09:00
jisangs 7d4ab86da5 모바일 화면에서 숫자 입력창 실행시 문제가 화면 위에 보여지도록 수정 2026-05-26 17:30:19 +09:00
jisangs 4a08dded14 리펙토링: 코드 최적화 2026-05-26 15:29:50 +09:00
jisangs 2779bf1b0e 문제 수 선택 기능 추가 - 5문제 / 10문제 2026-05-26 15:23:21 +09:00
jisangs 44e4d46c6c Synology NAS의 docker에 올릴 때, docker network 이름 변경 2026-05-26 14:57:29 +09:00
jisangs 909535c14e Dockerfile 에러 수정: pnpm build script 진행 오류 2026-05-25 23:07:49 +09:00
jisangs 05496025b7 Dockerfile 에러 수정: pnpm build script 진행 오류 2026-05-25 23:03:25 +09:00
jisangs 55e1587b7c Dockerfile 에러 수정: pnpm build script 진행 오류 2026-05-25 22:54:39 +09:00
jisangs 983321bee9 Dockerfile 에러 수정: pnpm build script 진행 오류 2026-05-25 22:51:13 +09:00
jisangs a0224b05cc Dockerfile 에러 수정: pnpm build script 진행 오류 2026-05-25 22:48:31 +09:00
jisangs b93bb6f4d9 Dockerfile 에러 수정: node:20-alpine -> node:22-alpine 2026-05-25 22:42:09 +09:00
jisangs d100f75ab9 Synology NAS의 docker에서 서비스 할 수 있도록 환경 설정 2026-05-25 22:37:18 +09:00
jisangs 6da37647ee 리펙토링: 코드 개선 2026-05-25 19:01:09 +09:00
jisangs 823490ba7c 문제 풀이 단계 표시 방식 변경: 숫자 -> 아이콘 2026-05-25 18:57:43 +09:00
jisangs 8387234f88 문제 풀이 화면 focus 조정 2026-05-25 18:53:22 +09:00
jisangs d56cada1ec 리팩토링: 코드 최적화 2026-05-25 18:42:13 +09:00
jisangs 5ee33bf0d3 괄호 앞 음수 분배 스테이지 추가 2026-05-23 12:15:53 +09:00
47 changed files with 5264 additions and 104 deletions
+18
View File
@@ -0,0 +1,18 @@
.git
.gitignore
.next
.pnpm-store
.turbo
coverage
docker-compose.override.yml
node_modules
playwright-report
test-results
.DS_Store
*.log
*.tsbuildinfo
.env
.env.local
.env.*.local
+4
View File
@@ -0,0 +1,4 @@
DATABASE_URL=
REDIS_URL=
NEXTAUTH_SECRET=
NEXTAUTH_URL=
+4
View File
@@ -0,0 +1,4 @@
DATABASE_URL=
REDIS_URL=
NEXTAUTH_SECRET=
NEXTAUTH_URL=https://math-quiz.jisangs.com
+99 -26
View File
@@ -32,29 +32,40 @@ AI 에이전트(Claude Code 등)가 이 프로젝트에서 작업할 때 따라
## 디렉토리 구조 및 파일 위치 규칙
```
app/ # Next.js App Router 루트
(game)/ # 게임 화면 라우트 그룹
select-type/ # 문제 유형 선택
select-mode/ # 게임 모드 선택
play/ # 문제 풀이
app/ # Next.js App Router 루트
(game)/ # 게임 화면 라우트 그룹
number-parentheses/ # 숫자 괄호 준비 화면
play/ # 숫자 괄호 풀이 화면
variable-parentheses/ # 미지수 괄호 준비 화면
play/ # 미지수 괄호 풀이 화면
select-mode/ # 게임 모드 선택 (미구현)
api/
auth/ # NextAuth.js 핸들러
problem/ # 문제 생성 API
record/ # 풀이 기록 저장 API
auth/ # NextAuth.js 핸들러
problem/ # 문제 생성 API
record/ # 풀이 기록 저장 API
layout.tsx
page.tsx # 시작 화면
page.tsx # 시작 화면 (학습 내용 선택)
components/
math/ # KaTeX 수식 컴포넌트
game/ # 게임 UI 컴포넌트
math/ # KaTeX 수식 컴포넌트
game/ # 게임 UI 컴포넌트
lib/
engine/ # 문제 생성 알고리즘 엔진
prisma.ts # Prisma Client 싱글턴
redis.ts # Redis Client 싱글턴
engine/ # 문제 생성 알고리즘 엔진
parenthesis-sign.ts # 숫자 괄호 엔진
parenthesis-latex.ts # 괄호 식 KaTeX 포맷 및 현재 항 강조 유틸
variable-parenthesis.ts # 미지수 괄호 엔진
types.ts # 숫자 괄호 타입
variable-types.ts # 미지수 괄호 타입
input-device.ts # 입력 장치 감지 미디어 쿼리 상수
prisma.ts # Prisma Client 싱글턴
redis.ts # Redis Client 싱글턴
store/
gameStore.ts # Zustand 전역 게임 상태
gameStore.ts # 숫자 괄호 Zustand 전역 상태
variableGameStore.ts # 미지수 괄호 Zustand 전역 상태
types/
*.d.ts # 타입 선언이 없는 서드파티 모듈 보강
prisma/
schema.prisma # MariaDB 모델 정의
.gitea/workflows/deploy.yml # CI/CD 워크플로우
schema.prisma # MariaDB 모델 정의
.gitea/workflows/deploy.yml # CI/CD 워크플로우
```
---
@@ -71,11 +82,51 @@ prisma/
- 모든 수학 수식은 KaTeX로 렌더링한다. 일반 텍스트나 Unicode 수식 문자를 직접 사용하지 않는다.
- KaTeX 컴포넌트는 `components/math/` 하위에 위치한다.
- 현재 수식 컴포넌트는 `katex.renderToString()`을 직접 사용한다. 별도 래퍼 라이브러리를 추가하기 전에 Next.js App Router 클라이언트 번들에서 런타임 문제가 없는지 확인한다.
- 괄호 식 수식 조립과 풀이 중 현재 항 강조는 `lib/engine/parenthesis-latex.ts`의 공통 유틸을 사용한다. 숫자/미지수 풀이 화면에 같은 포맷 로직을 중복 구현하지 않는다.
### 상태 관리
- 전역 게임 상태(점수, 진행도, 선택한 모드·유형)`store/gameStore.ts`의 Zustand store로 관리한다.
- 숫자 괄호 전역 게임 상태`store/gameStore.ts`, 미지수 괄호 전역 게임 상태`store/variableGameStore.ts`의 Zustand store로 관리한다. 학습 유형별로 store를 분리한다.
- 컴포넌트 내부의 로컬 상태는 `useState`를 사용한다.
- Zustand selector 안에서 매번 새 객체·배열을 반환하는 함수(`score()` 등)를 호출하지 않는다. 필요한 파생값은 구독한 state를 기준으로 컴포넌트 내부에서 계산하거나 안정적인 selector로 분리한다.
- store에는 세션 원본 상태와 상태 변경 action을 우선 두고, 렌더링 전용 파생값은 컴포넌트 또는 별도 순수 유틸에서 계산한다.
### 컴포넌트 배치
- 게임 화면에서 재사용되는 입력, 피드백, 점수 요약, 진행 단계 UI는 `components/game/` 하위에 둔다.
- 정답·오답 판정 라인처럼 피드백 화면과 결과 화면이 공유하는 표시는 `components/game/` 하위 공통 컴포넌트로 분리한다.
- 수식 렌더링 전용 컴포넌트는 `components/math/` 하위에 둔다.
- 라우트별 조립 컴포넌트(`NumberPlayClient`, `NumberParenthesesClient` 등)는 해당 `app/` 라우트 폴더에 둘 수 있다.
### 접근성 / 키보드 조작
- 문제 풀이 화면은 키보드만으로 진행할 수 있어야 한다.
- 정답·오답 피드백이 표시되면 다음 진행 버튼에 focus를 둔다.
- 최종 결과 화면이 표시되면 다시 시작 버튼에 focus를 둔다.
- 아이콘이나 기호만으로 상태를 표시할 때는 `aria-label`, `sr-only` 텍스트 등으로 의미를 함께 제공한다.
#### 숫자 괄호 풀이 화면 (`/number-parentheses/play`)
- 모든 항을 하나의 공유 input으로 입력한다.
- input 위 우상단에는 각 항의 진행 상태를 progress 스타일로 표시한다.
- 진행 상태는 값 입력 전에는 괄호 안 원래 항 값을 표시하고, 값 입력 중이거나 입력 후에는 사용자가 입력한 값을 표시한다.
- Enter를 누르면 다음 항 입력으로 전환되고, 마지막 항에서 Enter를 누르면 제출 동작을 실행한다. 별도 제출 버튼이 없어도 form submit 흐름이 유지되어야 한다.
- 풀이 중인 항의 수, 곱해지는 수, 해당 괄호는 KaTeX 색상으로 강조하고 나머지 항은 강조하지 않는다.
- 터치 기반 입력 환경에서는 input focus 시 커스텀 숫자 키패드를 표시한다. 키패드는 숫자, `+/-`, `.`, `⌫`, `↵` 버튼을 제공한다.
- 마우스/트랙패드처럼 hover 가능한 정밀 포인터가 있는 환경은 물리 키보드 사용 환경으로 간주하고 커스텀 숫자 키패드를 숨긴다. 웹 표준 API로 물리 키보드 연결 여부 자체를 안정적으로 판별할 수 없으므로 해상도 기준으로 분기하지 않는다. (`lib/input-device.ts``DESKTOP_INPUT_DEVICE_QUERY` 사용)
- 입력 중에는 `-`, `1.` 같은 임시 값을 허용할 수 있지만 제출 시에는 정상적인 양의 실수 또는 음의 실수 표현만 통과시킨다.
- 문제 풀이 화면 최초 진입 시 모바일 키보드를 강제로 열지 않는다. 사용자가 input을 직접 focus하거나 피드백 후 다음 문제로 넘어갈 때 input에 focus를 둔다.
- 터치 기반 입력 환경에서 input focus 시 `span.katex` 상단이 브라우저 화면 상단에 오도록 조정한다.
#### 미지수 괄호 풀이 화면 (`/variable-parentheses/play`)
- 답을 직접 입력하지 않는다. 각 항의 보기 버튼 중에서 선택한다.
- 항을 선택하면 자동으로 다음 항 보기로 focus가 이동한다.
- 마지막 항을 선택하면 별도 `정답 확인` 버튼 없이 즉시 제출되어 피드백 화면으로 전환된다.
- 입력 항 진행 상태는 progress 스타일로 표시한다. 선택 전에는 괄호 안 원래 항 값을 표시하고, 선택 후에는 고른 보기 값을 표시한다.
- 선택된 보기 버튼은 `aria-pressed`로 표시한다.
- 풀이 중인 항의 수, 곱해지는 수, 해당 괄호는 KaTeX 색상으로 강조하고 나머지 항은 강조하지 않는다.
### API / 백엔드
@@ -88,8 +139,17 @@ prisma/
- 문제 생성 로직은 `lib/engine/` 하위에 위치한다.
- 문제는 AI 호출 없이 알고리즘으로 생성한다. 외부 AI API를 문제 생성에 사용하지 않는다.
- 미지수의 계수는 항상 정수이어야 한다.
- 생성 파라미터: 숫자 자릿수, 괄호 깊이(`()` / `(){}` / `(){}[]`), 괄호 개수(13), 미지수(없음/x/x,y/x,y,z).
- 계수는 항상 정수이어야 한다.
- 문제 세션에서 중복을 방지할 때는 랜덤 id가 아니라 수학적 문제 조건을 나타내는 안정적인 key를 기준으로 한다.
- 모든 엔진은 전체 가능한 시드 목록을 모듈 로드 시 한 번 생성(`createAllProblemSeeds`)하고, 세션 생성 시 셔플 후 슬라이스하는 방식으로 중복 없이 출제한다.
- 숫자 괄호 엔진: `lib/engine/parenthesis-sign.ts` / 타입: `lib/engine/types.ts`
- 문제 구조는 `terms`, `operators`, `distributedTerms` 배열을 기준으로 한다. `a`, `b`, `distributedA`, `distributedB` 같은 2항 전용 필드를 추가하지 않는다.
- 채점 입력은 `{ terms: number[] }` 형태만 사용한다.
- 미지수 괄호 엔진: `lib/engine/variable-parenthesis.ts` / 타입: `lib/engine/variable-types.ts`
- 괄호 안 항 중 정확히 하나가 `정수 × x` 항이다.
- 문제 구조는 `terms`, `operators`, `correctTerms`, `choices` 배열을 기준으로 한다. `firstTerm`, `secondTerm`, `operator` 같은 2항 전용 필드를 추가하지 않는다.
- 각 항의 보기는 정답 계수의 절대값 기준 음수/양수 두 개를 셔플해 제공한다.
- 채점은 choice id 기반으로 수행한다.
---
@@ -117,30 +177,43 @@ NEXTAUTH_SECRET=your-secret
## 게임 도메인 컨텍스트
### 문제 유형
### 학습 유형
1. **괄호 음수 분배**: 괄호 앞 음수 곱셈 시 괄호 내 부호 변경 (예: `-2 × (3 - 6)`)
2. **이항 부호 변경**: 등식의 항을 이항할 때 부호 반전 (예: `3x - 5y + 8 = -2x + 2y - 2`)
| 유형 | 상태 | 라우트 | 답변 방식 |
|---|---|---|---|
| 숫자 괄호 풀기 | 구현 완료 | `/number-parentheses``/number-parentheses/play` | 직접 입력 |
| 미지수 괄호 풀기 | 구현 완료 | `/variable-parentheses``/variable-parentheses/play` | 보기 선택 |
| 분수 괄호 풀기 | 미구현 | — | — |
| 실수 괄호 풀기 | 미구현 | — | — |
### 게임 모드
| 모드 | 설명 |
|---|---|
| 스피드 퀴즈 | 제한 시간 내 최대 문제 풀기, Redis Sorted Set 기반 실시간 점수판 |
| 일반 풀이 | 시간 제한 없음, 틀린 유형 반복 출제 |
| 스피드 퀴즈 | 제한 시간 내 최대 문제 풀기, Redis Sorted Set 기반 실시간 점수판 (미구현) |
| 일반 풀이 | 시간 제한 없음, 틀린 유형 반복 출제 (미구현) |
### 화면 흐름
```
시작 화면 → 문제 유형 선택 → 게임 모드 선택 → 문제 풀이 → 결과 및 기록
학습 내용 선택(/) → 준비 화면 → 문제 풀이 → 결과
```
- 시작 화면(`/`)은 학습 내용 선택 화면이다. `숫자 괄호 풀기`, `미지수 괄호 풀기`가 활성화되어 있고 분수/실수 괄호는 disabled 상태다.
- 준비 화면은 문제 수 `[5] [10]`, 괄호 종류, 괄호 안 최대 항 수 `[2] [3] [4]` 버튼 그룹을 제공한다. 기본 문제 수는 `10`, 기본 최대 항 수는 `2`이며, 괄호 종류는 현재 `()`만 선택 가능하다.
- 준비 화면의 `연습 시작`은 Zustand 세션을 초기화하고 풀이 화면으로 이동한다.
- 풀이 화면은 URL의 `count`, `terms` 쿼리와 Zustand의 선택 문제 수·최대 항 수를 동기화한다. 허용 문제 수는 `5`, `10`이며 유효하지 않은 값은 `10`으로 처리한다. 허용 최대 항 수는 `2`, `3`, `4`이며 유효하지 않은 값은 `2`로 처리한다.
- 풀이 화면의 문제 수식은 현재 풀고 있는 항의 수, 곱해지는 수, 해당 괄호를 강조 색상으로 표시하고 나머지 항은 기본 색상으로 표시한다.
- 정답·오답 피드백은 `계산식 = 내가 입력한(고른) 답` 형식으로 표시한다. 정답은 `○`, 오답은 붉은색 답과 `×`, 굵은 정답값을 함께 보여준다.
- 최종 결과 화면은 지금까지 푼 문제를 `문제 = 내가 입력한(고른) 답` 형식으로 나열하고, 피드백 화면과 같은 정답·오답 색상 및 기호 규칙을 사용한다.
---
## CI/CD
- `.gitea/workflows/deploy.yml`이 CI/CD 진입점이다. main 브랜치 push 시 자동으로 빌드·배포된다.
- 패키지 설치는 반드시 `pnpm`을 사용한다. `npm` / `yarn` 명령을 사용하지 않는다.
- 빌드 확인은 `pnpm build`로 실행한다. standalone 배포 산출물에 필요한 정적 파일 복사까지 `postbuild` 스크립트가 처리한다.
- 배포 환경: Synology NAS + Docker Compose + Nginx Proxy Manager.
---
+35
View File
@@ -0,0 +1,35 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS base
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
RUN corepack enable
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
+76 -30
View File
@@ -24,28 +24,56 @@
---
## 문제 유형
## 학습 유형
1. **괄호 음수 분배** — 괄호 앞 음수 곱셈 시 괄호 내 부호 변경
- 예: `-2 × (3 - 6)`
2. **이항 부호 변경** — 등식의 항을 이항할 때 부호 반전
- 예: `3x - 5y + 8 = -2x + 2y - 2`
## 게임 모드
| 모드 | 설명 |
|---|---|
| 스피드 퀴즈 | 제한 시간 내 최대 문제 풀기, Redis Sorted Set 기반 실시간 점수판 |
| 일반 풀이 | 시간 제한 없음, 틀린 유형 반복 출제 |
| 유형 | 상태 | 답변 방식 |
|---|---|---|
| 숫자 괄호 풀기 | ✅ 구현 완료 | 직접 입력 |
| 미지수 괄호 풀기 | ✅ 구현 완료 | 보기 선택 |
| 분수 괄호 풀기 | 준비 중 | — |
| 실수 괄호 풀기 | 준비 중 | — |
## 화면 흐름
```
시작 화면 → 문제 유형 선택 → 게임 모드 선택 → 문제 풀이 → 결과 및 기록
학습 내용 선택(/) → 준비 화면 → 문제 풀이 → 결과
```
---
## 현재 구현 상태
### 공통
- 시작 화면(`/`)에서 학습 내용을 선택합니다. `숫자 괄호 풀기`, `미지수 괄호 풀기`가 활성 상태이고 분수·실수 괄호는 준비 중으로 비활성화되어 있습니다.
- 문제와 정답 수식은 KaTeX로 렌더링합니다.
- 풀이 중인 문제 수식은 현재 항, 곱해지는 수, 해당 괄호를 강조 색상으로 표시하고 나머지 항은 기본 색상으로 표시합니다.
- 진행 단계는 현재 문제, 완료된 정답, 완료된 오답, 남은 문제를 구분해서 표시합니다.
- 정답·오답 피드백은 `계산식 = 내가 입력한(고른) 답` 형식으로 표시합니다.
- 정답은 정답 색상 박스와 `○` 표시, 오답은 오답 색상 박스, 붉은색 답, `×`, 굵은 정답값을 표시합니다.
- 결과 화면은 전체 문제를 `문제 = 내가 입력한(고른) 답` 형식으로 나열하며, 피드백과 같은 색상·기호 규칙을 사용합니다.
- 피드백 화면 진입 시 다음 진행 버튼에, 결과 화면 진입 시 다시 시작 버튼에 자동으로 focus가 이동합니다.
### 숫자 괄호 풀기 (`/number-parentheses` → `/number-parentheses/play`)
- 준비 화면에서 문제 수 5 / 10과 괄호 안 최대 항 수 2 / 3 / 4를 선택하고 연습을 시작합니다. 기본값은 문제 수 10, 최대 항 수 2입니다.
- 괄호 종류는 현재 `()`만 선택 가능하고 `{}`, `[]`는 비활성화되어 있습니다.
- 풀이 화면에서 각 항을 직접 숫자로 입력합니다.
- 모든 항은 하나의 input에서 순서대로 입력하며, Enter로 다음 항으로 이동하고 마지막 항에서 Enter를 누르면 제출합니다.
- input 위 우상단의 progress 표시에서 각 항의 진행 상태를 보여줍니다. 입력 전에는 괄호 안 원래 항 값, 입력 중/입력 후에는 사용자가 입력한 값을 표시합니다.
- 터치 환경에서는 커스텀 숫자 키패드(숫자, `+/-`, `.`, `⌫`, `↵`)가 표시되며, 정밀 포인터가 있는 환경(마우스/트랙패드)에서는 숨깁니다.
### 미지수 괄호 풀기 (`/variable-parentheses` → `/variable-parentheses/play`)
- 준비 화면 구성은 숫자 괄호와 동일합니다.
- 풀이 화면에서 각 항을 보기 중에서 선택합니다.
- 각 항의 보기는 정답 계수의 절대값을 기준으로 음수/양수 두 개를 제공합니다.
- 항 선택 후 자동으로 다음 항 보기로 focus가 이동합니다.
- 마지막 항을 선택하면 별도 `정답 확인` 버튼 없이 즉시 피드백 화면으로 전환됩니다.
- 입력 항 progress 표시는 선택 전에는 괄호 안 원래 항 값, 선택 후에는 고른 보기 값을 표시합니다.
---
## 시작하기
### 사전 요구사항
@@ -89,38 +117,56 @@ pnpm build
pnpm start
```
`pnpm build`는 Next.js standalone 산출물에 필요한 정적 파일 복사까지 함께 처리합니다.
---
## 프로젝트 구조
```
app/ # Next.js App Router 루트
(game)/ # 게임 화면 라우트 그룹
select-type/ # 문제 유형 선택
select-mode/ # 게임 모드 선택
play/ # 문제 풀이
app/ # Next.js App Router 루트
(game)/ # 게임 화면 라우트 그룹
number-parentheses/ # 숫자 괄호 준비 화면
play/ # 숫자 괄호 풀이 화면
variable-parentheses/ # 미지수 괄호 준비 화면
play/ # 미지수 괄호 풀이 화면
api/
auth/ # NextAuth.js 핸들러
problem/ # 문제 생성 API
record/ # 풀이 기록 저장 API
auth/ # NextAuth.js 핸들러
problem/ # 문제 생성 API
record/ # 풀이 기록 저장 API
layout.tsx
page.tsx # 시작 화면
page.tsx # 시작 화면 (학습 내용 선택)
components/
math/ # KaTeX 수식 컴포넌트
game/ # 게임 UI 컴포넌트
math/ # KaTeX 수식 컴포넌트
game/ # 게임 UI 컴포넌트
lib/
engine/ # 문제 생성 알고리즘 엔진
prisma.ts # Prisma Client 싱글턴
redis.ts # Redis Client 싱글턴
engine/ # 문제 생성 알고리즘 엔진
parenthesis-sign.ts # 숫자 괄호 엔진
parenthesis-latex.ts # 괄호 식 KaTeX 포맷 및 현재 항 강조 유틸
variable-parenthesis.ts # 미지수 괄호 엔진
types.ts # 숫자 괄호 타입
variable-types.ts # 미지수 괄호 타입
input-device.ts # 입력 장치 감지 미디어 쿼리
prisma.ts # Prisma Client 싱글턴
redis.ts # Redis Client 싱글턴
store/
gameStore.ts # Zustand 전역 게임 상태
gameStore.ts # 숫자 괄호 Zustand 전역 상태
variableGameStore.ts # 미지수 괄호 Zustand 전역 상태
prisma/
schema.prisma # MariaDB 모델 정의
.gitea/workflows/deploy.yml # CI/CD 워크플로우
schema.prisma # MariaDB 모델 정의
.gitea/workflows/deploy.yml # CI/CD 워크플로우
```
---
## 다음 확장 방향
- 분수 괄호, 실수 괄호는 각 문제 생성 엔진이 준비되면 시작 화면에서 활성화합니다.
- `{}`, `[]` 괄호 종류는 생성 엔진과 상태/URL 파라미터 연동이 준비된 뒤 준비 화면에서 활성화합니다.
- 스피드 퀴즈 모드, 풀이 기록 저장, 로그인 기능은 DB/Redis/NextAuth 연동 후 추가합니다.
---
## 배포
- **플랫폼**: Synology NAS + Docker Compose + Nginx Proxy Manager
@@ -0,0 +1,31 @@
"use client";
import ParenthesesPreparationClient from "@/components/game/ParenthesesPreparationClient";
import { useGameStore } from "@/store/gameStore";
export default function NumberParenthesesClient() {
const selectedProblemCount = useGameStore(
(state) => state.selectedProblemCount,
);
const setProblemCount = useGameStore((state) => state.setProblemCount);
const selectedMaxTermCount = useGameStore(
(state) => state.selectedMaxTermCount,
);
const setMaxTermCount = useGameStore((state) => state.setMaxTermCount);
const initSession = useGameStore((state) => state.initSession);
return (
<ParenthesesPreparationClient
eyebrow="숫자 괄호 학습 준비"
title="숫자 괄호 풀기"
description="정수로 이루어진 괄호식을 풀며 음수 분배와 부호 변화를 연습합니다."
notice="현재는 소괄호 숫자 문제만 선택할 수 있습니다."
playHref={`/number-parentheses/play?count=${selectedProblemCount}&terms=${selectedMaxTermCount}`}
selectedProblemCount={selectedProblemCount}
selectedMaxTermCount={selectedMaxTermCount}
onProblemCountChange={setProblemCount}
onMaxTermCountChange={setMaxTermCount}
onStart={() => initSession(selectedProblemCount, selectedMaxTermCount)}
/>
);
}
+5
View File
@@ -0,0 +1,5 @@
import NumberParenthesesClient from "./NumberParenthesesClient";
export default function NumberParenthesesPage() {
return <NumberParenthesesClient />;
}
@@ -0,0 +1,287 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import { flushSync } from "react-dom";
import NumberAnswerForm from "@/components/game/NumberAnswerForm";
import type {
ActiveAnswerInput,
NumberAnswerFormHandle,
} from "@/components/game/NumberAnswerForm";
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 { MaxTermCount, ProblemCount } from "@/store/gameStore";
import { formatParenthesisExpressionLatex } from "@/lib/engine/parenthesis-latex";
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;
maxTermCount: MaxTermCount;
}
export default function NumberPlayClient({
problemCount,
maxTermCount,
}: NumberPlayClientProps) {
const session = useGameStore((state) => state.session);
const selectedProblemCount = useGameStore(
(state) => state.selectedProblemCount,
);
const setProblemCount = useGameStore((state) => state.setProblemCount);
const selectedMaxTermCount = useGameStore(
(state) => state.selectedMaxTermCount,
);
const setMaxTermCount = useGameStore((state) => state.setMaxTermCount);
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<NumberAnswerFormHandle>(null);
const [answers, setAnswers] = useState<string[]>(() =>
Array.from({ length: maxTermCount }, () => ""),
);
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 || session?.termCount !== maxTermCount;
const correctCount =
session?.results.filter((result) => result?.correct).length ?? 0;
const totalCount = session?.problems.length ?? 0;
useEffect(() => {
if (selectedProblemCount !== problemCount) {
setProblemCount(problemCount);
}
if (selectedMaxTermCount !== maxTermCount) {
setMaxTermCount(maxTermCount);
}
if (sessionNeedsInit) {
initSession(problemCount, maxTermCount);
}
}, [
initSession,
maxTermCount,
problemCount,
selectedMaxTermCount,
selectedProblemCount,
sessionProblemCount,
sessionNeedsInit,
setMaxTermCount,
setProblemCount,
]);
useEffect(() => {
setAnswers(Array.from({ length: currentProblem?.terms.length ?? maxTermCount }, () => ""));
setActiveInput(0);
setInputError("");
}, [currentProblem?.key, currentProblem?.terms.length, maxTermCount]);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (currentResult || !currentProblem) return;
const parsedAnswers = answers.map(parseAnswer);
if (parsedAnswers.some((answer) => answer === null)) {
setInputError("모든 항을 실수로 입력하세요.");
return;
}
setInputError("");
submitAnswer({ terms: parsedAnswers as number[] });
}
function handleAnswerChange(index: ActiveAnswerInput, value: string) {
setAnswers((currentAnswers) =>
currentAnswers.map((answer, answerIndex) =>
answerIndex === index ? value : answer,
),
);
}
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;
const problemLatex = submitted
? currentProblem.latexBefore
: formatParenthesisExpressionLatex({
multiplier: currentProblem.multiplier,
terms: currentProblem.terms,
operators: currentProblem.operators,
formatTermLatex: (term) => `${term}`,
activeTermIndex: activeInput,
});
const pendingAnswerLabels = currentProblem.terms.map((term, index) => {
if (index === 0) return String(term);
return String(
currentProblem.operators[index - 1] === "-" ? -term : term,
);
});
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>
<Link
href="/number-parentheses"
onClick={resetSession}
className="inline-flex h-10 items-center justify-center rounded-md bg-red-700 px-4 text-sm font-semibold text-white transition hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
>
</Link>
</div>
<div
ref={problemCardRef}
className="rounded-md bg-white px-6 pt-6 pb-4 shadow-sm ring-1 ring-slate-200 sm:p-8"
>
<div className="text-3xl font-semibold text-slate-950 sm:text-4xl">
<KatexRenderer latex={problemLatex} displayMode />
</div>
<div className="mt-6">
<StageIndicator
currentIndex={session.currentIndex}
results={session.results}
/>
</div>
</div>
{!submitted ? (
<NumberAnswerForm
ref={answerFormRef}
answers={answers}
activeInput={activeInput}
error={inputError}
pendingAnswerLabels={pendingAnswerLabels}
onActiveInputChange={setActiveInput}
onAnswerChange={handleAnswerChange}
onInputFocus={scrollKatexToViewportTop}
onSubmit={handleSubmit}
/>
) : null}
{currentResult ? (
<FeedbackPanel
result={currentResult}
problem={currentProblem}
isLastProblem={isLastProblem}
onNext={handleNextProblem}
/>
) : null}
</section>
</main>
);
}
@@ -0,0 +1,37 @@
import NumberPlayClient from "./NumberPlayClient";
import type { MaxTermCount, ProblemCount } from "@/store/gameStore";
interface NumberPlayPageProps {
searchParams?: {
count?: string | string[];
terms?: string | string[];
};
}
function parseProblemCount(count?: string | string[]): ProblemCount {
if (Array.isArray(count)) {
return parseProblemCount(count[0]);
}
return count === "5" ? 5 : 10;
}
function parseMaxTermCount(terms?: string | string[]): MaxTermCount {
if (Array.isArray(terms)) {
return parseMaxTermCount(terms[0]);
}
if (terms === "3") return 3;
if (terms === "4") return 4;
return 2;
}
export default function NumberPlayPage({ searchParams }: NumberPlayPageProps) {
return (
<NumberPlayClient
problemCount={parseProblemCount(searchParams?.count)}
maxTermCount={parseMaxTermCount(searchParams?.terms)}
/>
);
}
-30
View File
@@ -1,30 +0,0 @@
import Link from "next/link";
export default function PlayPage() {
return (
<main className="flex min-h-screen items-center bg-white px-6 py-12 text-slate-950">
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8">
<div className="space-y-4">
<p className="text-sm font-semibold text-emerald-700">
</p>
<h1 className="text-4xl font-bold tracking-normal sm:text-5xl">
</h1>
<p className="max-w-2xl text-lg leading-8 text-slate-700">
.
</p>
</div>
<div>
<Link
href="/"
className="inline-flex h-11 items-center justify-center rounded-md border border-slate-300 px-5 text-base font-semibold text-slate-800 transition hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
>
</Link>
</div>
</section>
</main>
);
}
@@ -0,0 +1,35 @@
"use client";
import ParenthesesPreparationClient from "@/components/game/ParenthesesPreparationClient";
import { useVariableGameStore } from "@/store/variableGameStore";
export default function VariableParenthesesClient() {
const selectedProblemCount = useVariableGameStore(
(state) => state.selectedProblemCount,
);
const setProblemCount = useVariableGameStore(
(state) => state.setProblemCount,
);
const selectedMaxTermCount = useVariableGameStore(
(state) => state.selectedMaxTermCount,
);
const setMaxTermCount = useVariableGameStore(
(state) => state.setMaxTermCount,
);
const initSession = useVariableGameStore((state) => state.initSession);
return (
<ParenthesesPreparationClient
eyebrow="미지수 괄호 학습 준비"
title="미지수 괄호 풀기"
description="괄호 안의 한 항에 미지수 x가 들어간 식을 풀며 부호 변화를 연습합니다."
notice="현재는 소괄호와 미지수 x 문제만 선택할 수 있습니다."
playHref={`/variable-parentheses/play?count=${selectedProblemCount}&terms=${selectedMaxTermCount}`}
selectedProblemCount={selectedProblemCount}
selectedMaxTermCount={selectedMaxTermCount}
onProblemCountChange={setProblemCount}
onMaxTermCountChange={setMaxTermCount}
onStart={() => initSession(selectedProblemCount, selectedMaxTermCount)}
/>
);
}
+5
View File
@@ -0,0 +1,5 @@
import VariableParenthesesClient from "./VariableParenthesesClient";
export default function VariableParenthesesPage() {
return <VariableParenthesesClient />;
}
@@ -0,0 +1,233 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { flushSync } from "react-dom";
import StageIndicator from "@/components/game/StageIndicator";
import VariableChoiceAnswerForm from "@/components/game/VariableChoiceAnswerForm";
import VariableFeedbackPanel from "@/components/game/VariableFeedbackPanel";
import VariableScoreSummary from "@/components/game/VariableScoreSummary";
import KatexRenderer from "@/components/math/KatexRenderer";
import { formatVariableTermLatex } from "@/lib/engine/variable-parenthesis";
import { formatParenthesisExpressionLatex } from "@/lib/engine/parenthesis-latex";
import {
useVariableGameStore,
type VariableMaxTermCount,
type VariableProblemCount,
} from "@/store/variableGameStore";
interface VariablePlayClientProps {
problemCount: VariableProblemCount;
maxTermCount: VariableMaxTermCount;
}
export default function VariablePlayClient({
problemCount,
maxTermCount,
}: VariablePlayClientProps) {
const session = useVariableGameStore((state) => state.session);
const selectedProblemCount = useVariableGameStore(
(state) => state.selectedProblemCount,
);
const setProblemCount = useVariableGameStore(
(state) => state.setProblemCount,
);
const selectedMaxTermCount = useVariableGameStore(
(state) => state.selectedMaxTermCount,
);
const setMaxTermCount = useVariableGameStore(
(state) => state.setMaxTermCount,
);
const initSession = useVariableGameStore((state) => state.initSession);
const submitAnswer = useVariableGameStore((state) => state.submitAnswer);
const nextProblem = useVariableGameStore((state) => state.nextProblem);
const resetSession = useVariableGameStore((state) => state.resetSession);
const [selectedChoiceIds, setSelectedChoiceIds] = useState<(string | null)[]>(
() => Array.from({ length: maxTermCount }, () => null),
);
const [activeTermIndex, setActiveTermIndex] = useState(0);
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 || session?.termCount !== maxTermCount;
const correctCount =
session?.results.filter((result) => result?.correct).length ?? 0;
const totalCount = session?.problems.length ?? 0;
useEffect(() => {
if (selectedProblemCount !== problemCount) {
setProblemCount(problemCount);
}
if (selectedMaxTermCount !== maxTermCount) {
setMaxTermCount(maxTermCount);
}
if (sessionNeedsInit) {
initSession(problemCount, maxTermCount);
}
}, [
initSession,
maxTermCount,
problemCount,
selectedMaxTermCount,
selectedProblemCount,
sessionProblemCount,
sessionNeedsInit,
setMaxTermCount,
setProblemCount,
]);
useEffect(() => {
setSelectedChoiceIds(
Array.from({ length: currentProblem?.terms.length ?? maxTermCount }, () => null),
);
setActiveTermIndex(0);
}, [currentProblem?.key, currentProblem?.terms.length, maxTermCount]);
function handleSelectChoice(termIndex: number, choiceId: string) {
const nextSelectedChoiceIds = [...selectedChoiceIds];
nextSelectedChoiceIds[termIndex] = choiceId;
setSelectedChoiceIds(nextSelectedChoiceIds);
if (termIndex < nextSelectedChoiceIds.length - 1) {
setActiveTermIndex(termIndex + 1);
return;
}
if (currentResult || !currentProblem) return;
if (
nextSelectedChoiceIds.some((selectedChoiceId) => selectedChoiceId === null)
) {
return;
}
submitAnswer({ selectedChoiceIds: nextSelectedChoiceIds });
}
function handleNextProblem() {
flushSync(() => {
nextProblem();
});
}
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 (
<VariableScoreSummary
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;
const problemLatex = submitted
? currentProblem.latexBefore
: formatParenthesisExpressionLatex({
multiplier: currentProblem.multiplier,
terms: currentProblem.terms,
operators: currentProblem.operators,
formatTermLatex: formatVariableTermLatex,
activeTermIndex,
});
const pendingChoiceLabels = currentProblem.terms.map((term, index) =>
formatVariableTermLatex({
...term,
coefficient:
index > 0 && currentProblem.operators[index - 1] === "-"
? -term.coefficient
: term.coefficient,
}),
);
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>
<Link
href="/variable-parentheses"
onClick={resetSession}
className="inline-flex h-10 items-center justify-center rounded-md bg-red-700 px-4 text-sm font-semibold text-white transition hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
>
</Link>
</div>
<div className="rounded-md bg-white px-6 pt-6 pb-4 shadow-sm ring-1 ring-slate-200 sm:p-8">
<div className="text-3xl font-semibold text-slate-950 sm:text-4xl">
<KatexRenderer latex={problemLatex} displayMode />
</div>
<div className="mt-6">
<StageIndicator
currentIndex={session.currentIndex}
results={session.results}
/>
</div>
</div>
{!submitted ? (
<VariableChoiceAnswerForm
choices={currentProblem.choices}
selectedChoiceIds={selectedChoiceIds}
activeTermIndex={activeTermIndex}
pendingChoiceLabels={pendingChoiceLabels}
onSelectChoice={handleSelectChoice}
/>
) : null}
{currentResult ? (
<VariableFeedbackPanel
result={currentResult}
problem={currentProblem}
isLastProblem={isLastProblem}
onNext={handleNextProblem}
/>
) : null}
</section>
</main>
);
}
@@ -0,0 +1,42 @@
import VariablePlayClient from "./VariablePlayClient";
import type {
VariableMaxTermCount,
VariableProblemCount,
} from "@/store/variableGameStore";
interface VariablePlayPageProps {
searchParams?: {
count?: string | string[];
terms?: string | string[];
};
}
function parseProblemCount(count?: string | string[]): VariableProblemCount {
if (Array.isArray(count)) {
return parseProblemCount(count[0]);
}
return count === "5" ? 5 : 10;
}
function parseMaxTermCount(terms?: string | string[]): VariableMaxTermCount {
if (Array.isArray(terms)) {
return parseMaxTermCount(terms[0]);
}
if (terms === "3") return 3;
if (terms === "4") return 4;
return 2;
}
export default function VariablePlayPage({
searchParams,
}: VariablePlayPageProps) {
return (
<VariablePlayClient
problemCount={parseProblemCount(searchParams?.count)}
maxTermCount={parseMaxTermCount(searchParams?.terms)}
/>
);
}
+1
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "katex/dist/katex.min.css";
import "./globals.css";
const geistSans = localFont({
+76 -13
View File
@@ -1,34 +1,97 @@
import Link from "next/link";
type Subject =
| {
label: string;
description: string;
href: string;
disabled: false;
}
| {
label: string;
description: string;
disabled: true;
};
const subjects: Subject[] = [
{
label: "숫자 괄호 풀기",
description: "정수로 이루어진 괄호식을 풀며 부호 변화를 연습합니다.",
href: "/number-parentheses",
disabled: false,
},
{
label: "분수 괄호 풀기",
description: "분수가 들어간 괄호식은 이후 단계에서 추가됩니다.",
disabled: true,
},
{
label: "실수 괄호 풀기",
description: "실수가 들어간 괄호식은 이후 단계에서 추가됩니다.",
disabled: true,
},
{
label: "미지수 괄호 풀기",
description: "괄호 안에 미지수 x가 들어간 식의 부호 변화를 연습합니다.",
href: "/variable-parentheses",
disabled: false,
},
];
export default function Home() {
return (
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8">
<div className="space-y-4">
<p className="text-sm font-semibold text-emerald-700">
2
2 -
</p>
<h1 className="text-4xl font-bold tracking-normal sm:text-5xl">
</h1>
<p className="max-w-2xl text-lg leading-8 text-slate-700">
.
.
</p>
</div>
<div>
<Link
href="/play"
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"
>
</Link>
<div className="grid gap-3 sm:grid-cols-2">
{subjects.map((subject) => {
if (!subject.disabled) {
return (
<Link
key={subject.label}
href={subject.href}
className="flex min-h-32 flex-col justify-between rounded-md bg-white p-5 text-left shadow-sm ring-1 ring-slate-200 transition hover:-translate-y-0.5 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
>
<span className="text-xl font-bold text-slate-950">
{subject.label}
</span>
<span className="mt-4 text-sm leading-6 text-slate-600">
{subject.description}
</span>
</Link>
);
}
return (
<button
key={subject.label}
type="button"
disabled
className="flex min-h-32 cursor-not-allowed flex-col justify-between rounded-md bg-slate-100 p-5 text-left text-slate-400 ring-1 ring-slate-200"
>
<span className="text-xl font-bold">{subject.label}</span>
<span className="mt-4 text-sm leading-6">
{subject.description}
</span>
</button>
);
})}
</div>
<div className="border-l-4 border-emerald-700 pl-4 text-sm leading-6 text-slate-600">
<p> .</p>
<p> .</p>
<p> .</p>
</div>
</section>
</main>
+70
View File
@@ -0,0 +1,70 @@
import type { ReactNode } from "react";
import KatexRenderer from "@/components/math/KatexRenderer";
interface AnswerResultLineProps {
expressionLatex: string;
userAnswerLatex: string;
correctAnswerLatex: string;
correct: boolean;
prefix?: ReactNode;
userAnswerContent?: ReactNode;
correctAnswerContent?: ReactNode;
}
export default function AnswerResultLine({
expressionLatex,
userAnswerLatex,
correctAnswerLatex,
correct,
prefix,
userAnswerContent,
correctAnswerContent,
}: AnswerResultLineProps) {
return (
<li
className={`flex flex-wrap items-center gap-x-2 gap-y-1 rounded-md px-4 py-3 text-lg font-semibold ring-1 ring-inset ${
correct
? "bg-emerald-100 text-emerald-950 ring-emerald-200"
: "bg-red-100 text-red-950 ring-red-200"
}`}
>
{prefix}
<KatexRenderer latex={expressionLatex} output="mathml" />
<span aria-hidden="true">=</span>
{userAnswerContent ?? (
<span className={correct ? undefined : "text-red-700"}>
<KatexRenderer latex={userAnswerLatex} output="mathml" />
</span>
)}
{correct ? (
<span
aria-label="정답"
role="img"
className="inline-flex h-7 items-center text-2xl leading-none text-emerald-700"
>
</span>
) : (
<>
<span
aria-label="오답"
role="img"
className="mr-2 inline-flex h-7 items-center text-2xl leading-none text-red-700"
>
×
</span>
<span className="text-slate-700">
(
{correctAnswerContent ?? (
<span className="font-bold">
<KatexRenderer latex={correctAnswerLatex} output="mathml" />
</span>
)}
)
</span>
</>
)}
</li>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useEffect, useRef } from "react";
import AnswerResultLine from "@/components/game/AnswerResultLine";
import type { GradeResult, ParenthesisSignProblem } from "@/lib/engine/types";
interface FeedbackPanelProps {
result: GradeResult;
problem: ParenthesisSignProblem;
isLastProblem: boolean;
onNext: () => void;
}
function formatSignedNumber(value: number) {
return value < 0 ? `(${value})` : `${value}`;
}
export default function FeedbackPanel({
result,
problem,
isLastProblem,
onNext,
}: FeedbackPanelProps) {
const nextButtonRef = useRef<HTMLButtonElement>(null);
const signedTerms = problem.terms.map((term, index) => {
if (index === 0) return term;
return problem.operators[index - 1] === "-" ? -term : term;
});
const feedbackItems = signedTerms.map((term, index) => ({
formulaLatex: `${formatSignedNumber(
problem.multiplier,
)}\\times ${formatSignedNumber(term)}`,
userAnswer: result.userAnswer[index],
correctAnswer: result.correctAnswer[index],
correct: result.userAnswer[index] === result.correctAnswer[index],
}));
useEffect(() => {
nextButtonRef.current?.focus();
}, []);
return (
<div
className={`rounded-md p-6 shadow-sm ring-1 ${
result.correct
? "bg-emerald-50 text-emerald-950 ring-emerald-200"
: "bg-red-50 text-red-950 ring-red-200"
}`}
>
<p className="text-lg font-bold">
{result.correct ? "정답입니다." : "오답입니다."}
</p>
<ul className="mt-5 space-y-3">
{feedbackItems.map((item, index) => (
<AnswerResultLine
key={index}
expressionLatex={item.formulaLatex}
userAnswerLatex={`${item.userAnswer}`}
correctAnswerLatex={`${item.correctAnswer}`}
correct={item.correct}
/>
))}
</ul>
<button
ref={nextButtonRef}
type="button"
onClick={onNext}
className="mt-5 inline-flex h-11 items-center justify-center rounded-md bg-slate-950 px-5 text-base font-semibold text-white transition hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2"
>
{isLastProblem ? "결과 보기" : "다음 문제"}
</button>
</div>
);
}
+337
View File
@@ -0,0 +1,337 @@
"use client";
import {
forwardRef,
useCallback,
useEffect,
useId,
useImperativeHandle,
useRef,
useState,
} from "react";
import type { FocusEvent, FormEvent } from "react";
import { DESKTOP_INPUT_DEVICE_QUERY } from "@/lib/input-device";
import KatexRenderer from "@/components/math/KatexRenderer";
export type ActiveAnswerInput = number;
const ANSWER_INPUT_PATTERN = /^-?\d*(?:\.\d*)?$/;
const KEYPAD_KEYS = [
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"+/-",
"0",
];
export interface NumberAnswerFormHandle {
focusInput: () => void;
}
interface NumberAnswerFormProps {
answers: string[];
activeInput: ActiveAnswerInput;
error: string;
pendingAnswerLabels: string[];
onAnswerChange: (index: ActiveAnswerInput, value: string) => void;
onActiveInputChange: (input: ActiveAnswerInput) => void;
onInputBlur?: () => void;
onInputFocus?: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
}
const NumberAnswerForm = forwardRef<
NumberAnswerFormHandle,
NumberAnswerFormProps
>(function NumberAnswerForm(
{
answers,
activeInput,
error,
pendingAnswerLabels,
onAnswerChange,
onActiveInputChange,
onInputBlur,
onInputFocus,
onSubmit,
},
ref,
) {
const inputId = useId();
const formRef = useRef<HTMLFormElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const blurTimeoutRef = useRef<number | null>(null);
const [keypadOpen, setKeypadOpen] = useState(false);
const [desktopInputDevice, setDesktopInputDevice] = useState(false);
const activeAnswer = answers[activeInput] ?? "";
const activeLabel = `${activeInput + 1}`;
const activeDescription = `${activeLabel}의 답을 입력하세요.`;
const lastInputIndex = answers.length - 1;
const activeEnterKeyHint = activeInput < lastInputIndex ? "next" : "done";
function updateAnswer(value: string) {
if (!ANSWER_INPUT_PATTERN.test(value)) return;
onAnswerChange(activeInput, value);
}
function handleInputFocus() {
if (blurTimeoutRef.current !== null) {
window.clearTimeout(blurTimeoutRef.current);
blurTimeoutRef.current = null;
}
setKeypadOpen(true);
onInputFocus?.();
}
function handleInputBlur(event: FocusEvent<HTMLInputElement>) {
const nextFocusedElement = event.relatedTarget;
if (
nextFocusedElement instanceof Node &&
formRef.current?.contains(nextFocusedElement)
) {
return;
}
blurTimeoutRef.current = window.setTimeout(() => {
blurTimeoutRef.current = null;
if (formRef.current?.contains(document.activeElement)) {
setKeypadOpen(true);
return;
}
setKeypadOpen(false);
onInputBlur?.();
}, 0);
}
const focusInput = useCallback(() => {
const input = inputRef.current;
if (!input) return;
const wasFocused = document.activeElement === input;
input.focus({ preventScroll: true });
if (wasFocused) {
onInputFocus?.();
}
}, [onInputFocus]);
useImperativeHandle(ref, () => ({ focusInput }), [focusInput]);
useEffect(() => {
const mediaQuery = window.matchMedia(DESKTOP_INPUT_DEVICE_QUERY);
const updateInputDevice = () => {
setDesktopInputDevice(mediaQuery.matches);
};
updateInputDevice();
mediaQuery.addEventListener("change", updateInputDevice);
return () => {
if (blurTimeoutRef.current !== null) {
window.clearTimeout(blurTimeoutRef.current);
}
mediaQuery.removeEventListener("change", updateInputDevice);
};
}, []);
function toggleMinus() {
onAnswerChange(
activeInput,
activeAnswer.startsWith("-") ? activeAnswer.slice(1) : `-${activeAnswer}`,
);
focusInput();
}
function appendKeypadValue(value: string) {
updateAnswer(`${activeAnswer}${value}`);
focusInput();
}
function deleteLastCharacter() {
updateAnswer(activeAnswer.slice(0, -1));
focusInput();
}
function moveToInput(input: ActiveAnswerInput, reset = false) {
onActiveInputChange(input);
if (reset) {
onAnswerChange(input, "");
}
focusInput();
}
function handleEnter() {
if (activeInput < lastInputIndex) {
moveToInput(activeInput + 1);
return;
}
formRef.current?.requestSubmit();
focusInput();
}
const answerButtons = answers.map((value, index) => ({
index,
label: `${index + 1}`,
value,
}));
return (
<form
ref={formRef}
onSubmit={onSubmit}
className="rounded-md bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8"
>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<label
htmlFor={inputId}
className="text-sm font-semibold text-slate-500"
>
{activeDescription}
</label>
<ol
className="flex min-w-40 items-center gap-1"
aria-label="입력 항 진행 상태"
>
{answerButtons.map((button) => {
const selected = activeInput === button.index;
const completed = button.value !== "";
const displayValue =
button.value || pendingAnswerLabels[button.index];
return (
<li
key={button.index}
className="min-w-0 flex-1"
aria-label={`${button.label} ${
completed
? `입력값 ${button.value}`
: `원래 항 ${displayValue}, 미입력`
}${selected ? ", 현재 입력 중" : ""}`}
>
<span
className={`block truncate text-center text-xs font-bold ${
selected || completed ? "text-emerald-800" : "text-slate-500"
}`}
>
<KatexRenderer latex={displayValue} />
</span>
<span
className={`mt-1 block h-1.5 rounded-full ${
completed
? "bg-emerald-700"
: selected
? "bg-emerald-300 ring-1 ring-emerald-700"
: "bg-slate-200"
}`}
/>
</li>
);
})}
</ol>
</div>
<input
id={inputId}
ref={inputRef}
type="text"
value={activeAnswer}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
onChange={(event) => updateAnswer(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleEnter();
}
}}
autoComplete="off"
enterKeyHint={activeEnterKeyHint}
inputMode="none"
pattern="-?[0-9]*(\.[0-9]*)?"
className="h-12 w-full min-w-0 rounded-md border border-slate-300 bg-white px-3 text-lg font-semibold text-slate-950 outline-none transition focus:border-emerald-600 focus:ring-2 focus:ring-emerald-200 sm:px-4"
/>
{keypadOpen && !desktopInputDevice ? (
<div
className="grid grid-cols-3 gap-2"
role="group"
aria-label={`${activeLabel} 숫자 입력 키패드`}
>
{KEYPAD_KEYS.map((key) => (
<button
key={key}
type="button"
onPointerDown={(event) => event.preventDefault()}
onClick={() =>
key === "+/-" ? toggleMinus() : appendKeypadValue(key)
}
aria-label={
key === "+/-"
? `${activeLabel} 양수 음수 기호 전환`
: `${activeLabel} 숫자 ${key} 입력`
}
className="inline-flex h-12 items-center justify-center rounded-md border border-slate-300 bg-white text-lg font-semibold text-slate-900 shadow-sm transition hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
>
{key}
</button>
))}
<button
type="button"
onPointerDown={(event) => event.preventDefault()}
onClick={() => appendKeypadValue(".")}
aria-label={`${activeLabel} 소수점 입력`}
className="inline-flex h-12 items-center justify-center rounded-md border border-slate-300 bg-white text-lg font-semibold text-slate-900 shadow-sm transition hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
>
.
</button>
<button
type="button"
onPointerDown={(event) => event.preventDefault()}
onClick={deleteLastCharacter}
aria-label={`${activeLabel} 마지막 글자 삭제`}
className="inline-flex h-12 items-center justify-center rounded-md border border-slate-300 bg-white text-xl font-semibold text-slate-800 shadow-sm transition hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
>
</button>
<button
type="button"
onPointerDown={(event) => event.preventDefault()}
onClick={handleEnter}
aria-label={
activeInput < lastInputIndex
? "현재 항 입력 완료 후 다음 항으로 이동"
: "마지막 항 입력 완료 후 제출"
}
className="col-span-2 inline-flex h-12 items-center justify-center rounded-md bg-emerald-700 text-base font-semibold text-white shadow-sm transition hover:bg-emerald-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
>
</button>
</div>
) : null}
</div>
{error ? (
<p className="mt-3 text-sm font-semibold text-red-700">{error}</p>
) : null}
</form>
);
});
export default NumberAnswerForm;
@@ -0,0 +1,206 @@
"use client";
import Link from "next/link";
import { useEffect, useRef } from "react";
export type PreparationProblemCount = 5 | 10;
export type PreparationMaxTermCount = 2 | 3 | 4;
interface ParenthesesPreparationClientProps {
eyebrow: string;
title: string;
description: string;
notice: string;
playHref: string;
selectedProblemCount: PreparationProblemCount;
selectedMaxTermCount: PreparationMaxTermCount;
onProblemCountChange: (count: PreparationProblemCount) => void;
onMaxTermCountChange: (count: PreparationMaxTermCount) => void;
onStart: () => void;
}
const problemCounts: PreparationProblemCount[] = [5, 10];
const maxTermCounts: PreparationMaxTermCount[] = [2, 3, 4];
const buttonGroupClassName =
"inline-flex gap-1 rounded-md border border-slate-300 bg-white p-1 shadow-sm";
function optionButtonClassName({
selected,
disabled = false,
}: {
selected: boolean;
disabled?: boolean;
}) {
return `h-10 min-w-16 rounded px-4 text-base font-semibold transition focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 ${
selected ? "bg-emerald-700 text-white" : "text-slate-700 hover:bg-slate-100"
} ${
disabled
? "cursor-not-allowed bg-slate-100 text-slate-400 hover:bg-slate-100"
: ""
}`;
}
const bracketKinds = [
{
label: "( )",
name: "소괄호",
selected: true,
disabled: false,
},
{
label: "{ }",
name: "중괄호",
selected: false,
disabled: true,
},
{
label: "[ ]",
name: "대괄호",
selected: false,
disabled: true,
},
];
export default function ParenthesesPreparationClient({
eyebrow,
title,
description,
notice,
playHref,
selectedProblemCount,
selectedMaxTermCount,
onProblemCountChange,
onMaxTermCountChange,
onStart,
}: ParenthesesPreparationClientProps) {
const startLinkRef = useRef<HTMLAnchorElement>(null);
useEffect(() => {
startLinkRef.current?.focus();
}, []);
return (
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8">
<div className="space-y-4">
<p className="text-sm font-semibold text-emerald-700">{eyebrow}</p>
<h1 className="text-4xl font-bold tracking-normal sm:text-5xl">
{title}
</h1>
<p className="max-w-2xl text-lg leading-8 text-slate-700">
{description}
</p>
</div>
<div className="flex flex-col gap-6">
<fieldset className="space-y-3">
<legend className="text-sm font-semibold text-slate-700">
</legend>
<div
className={buttonGroupClassName}
role="group"
aria-label="문제 수 선택"
>
{problemCounts.map((count) => {
const selected = selectedProblemCount === count;
return (
<button
key={count}
type="button"
onClick={() => onProblemCountChange(count)}
aria-pressed={selected}
className={optionButtonClassName({ selected })}
>
{count}
</button>
);
})}
</div>
</fieldset>
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
<fieldset className="space-y-3">
<legend className="text-sm font-semibold text-slate-700">
</legend>
<div
className={buttonGroupClassName}
role="group"
aria-label="괄호 종류 선택"
>
{bracketKinds.map((bracketKind) => (
<button
key={bracketKind.label}
type="button"
disabled={bracketKind.disabled}
aria-pressed={bracketKind.selected}
aria-label={`${bracketKind.name} ${
bracketKind.disabled ? "준비 중" : "선택됨"
}`}
className={optionButtonClassName({
selected: bracketKind.selected,
disabled: bracketKind.disabled,
})}
>
{bracketKind.label}
</button>
))}
</div>
</fieldset>
<fieldset className="space-y-3">
<legend className="text-sm font-semibold text-slate-700">
</legend>
<div
className={buttonGroupClassName}
role="group"
aria-label="괄호 안 최대 항 수 선택"
>
{maxTermCounts.map((count) => {
const selected = selectedMaxTermCount === count;
return (
<button
key={count}
type="button"
onClick={() => onMaxTermCountChange(count)}
aria-pressed={selected}
className={optionButtonClassName({ selected })}
>
{count}
</button>
);
})}
</div>
</fieldset>
</div>
<div className="flex flex-wrap gap-3">
<Link
ref={startLinkRef}
href={playHref}
onClick={onStart}
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"
>
</Link>
<Link
href="/"
className="inline-flex h-12 items-center justify-center rounded-md border border-slate-300 bg-white px-6 text-base font-semibold text-slate-700 transition hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
>
</Link>
</div>
</div>
<div className="border-l-4 border-emerald-700 pl-4 text-sm leading-6 text-slate-600">
<p>{notice}</p>
</div>
</section>
</main>
);
}
+149
View File
@@ -0,0 +1,149 @@
"use client";
import Link from "next/link";
import { useEffect, useRef } from "react";
import AnswerResultLine from "@/components/game/AnswerResultLine";
import KatexRenderer from "@/components/math/KatexRenderer";
import type { GradeResult, ParenthesisSignProblem } from "@/lib/engine/types";
interface ScoreSummaryProps {
correctCount: number;
totalCount: number;
problems: ParenthesisSignProblem[];
results: (GradeResult | null)[];
onRestart: () => void;
}
function formatAnswerLatex(terms: number[]) {
return terms
.map((term, index) => {
if (index === 0) return `${term}`;
const operator = term < 0 ? "-" : "+";
return `${operator} ${Math.abs(term)}`;
})
.join(" ");
}
function renderUserAnswerPair(result: GradeResult) {
return (
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
{result.userAnswer.map((term, index) => {
const operator = term < 0 ? "-" : "+";
const correct = term === result.correctAnswer[index];
const displayLatex =
index === 0 ? `${term}` : `${operator} ${Math.abs(term)}`;
return (
<span key={index} className={correct ? undefined : "text-red-700"}>
<KatexRenderer latex={displayLatex} output="mathml" />
</span>
);
})}
</span>
);
}
function renderCorrectAnswerPair(result: GradeResult) {
return (
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
{result.correctAnswer.map((term, index) => {
const operator = term < 0 ? "-" : "+";
const correct = result.userAnswer[index] === term;
const displayLatex =
index === 0 ? `${term}` : `${operator} ${Math.abs(term)}`;
return (
<span
key={index}
className={correct ? undefined : "font-bold text-blue-700"}
>
<KatexRenderer latex={displayLatex} output="mathml" />
</span>
);
})}
</span>
);
}
export default function ScoreSummary({
correctCount,
totalCount,
problems,
results,
onRestart,
}: ScoreSummaryProps) {
const restartButtonRef = useRef<HTMLButtonElement>(null);
const solvedItems = problems
.map((problem, index) => ({
problem,
result: results[index],
}))
.filter(
(item): item is { problem: ParenthesisSignProblem; result: GradeResult } =>
item.result !== null,
);
useEffect(() => {
restartButtonRef.current?.focus();
}, []);
return (
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8">
<div className="space-y-3">
<p className="text-sm font-semibold text-emerald-700">
</p>
<h1 className="text-4xl font-bold tracking-normal sm:text-5xl">
</h1>
<p className="text-xl font-semibold text-slate-800">
{correctCount} / {totalCount}
</p>
</div>
<ol className="space-y-3">
{solvedItems.map(({ problem, result }, index) => {
const userAnswerLatex = formatAnswerLatex(result.userAnswer);
return (
<AnswerResultLine
key={problem.key}
expressionLatex={problem.latexBefore}
userAnswerLatex={userAnswerLatex}
correctAnswerLatex={problem.latexAfter}
correct={result.correct}
userAnswerContent={renderUserAnswerPair(result)}
correctAnswerContent={renderCorrectAnswerPair(result)}
prefix={
<span className="mr-1 text-sm font-bold text-slate-500">
{index + 1}.
</span>
}
/>
);
})}
</ol>
<div className="flex flex-wrap gap-3">
<button
ref={restartButtonRef}
type="button"
onClick={onRestart}
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"
>
</button>
<Link
href="/"
className="inline-flex h-12 items-center justify-center rounded-md border border-slate-300 px-6 text-base font-semibold text-slate-800 transition hover:bg-white focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
>
</Link>
</div>
</section>
</main>
);
}
+62
View File
@@ -0,0 +1,62 @@
interface StageResult {
correct: boolean;
}
interface StageIndicatorProps {
currentIndex: number;
results: (StageResult | null)[];
}
export default function StageIndicator({
currentIndex,
results,
}: StageIndicatorProps) {
const completedCount = results.filter((result) => result !== null).length;
const correctCount = results.filter((result) => result?.correct).length;
const incorrectCount = results.filter((result) => result?.correct === false)
.length;
return (
<div className="w-full">
<p className="sr-only">
{results.length} {completedCount} , {" "}
{correctCount}, {incorrectCount}, {currentIndex + 1}
</p>
<ol
className="flex w-full items-center gap-1"
aria-label="문제 풀이 진행 단계"
>
{results.map((result, index) => {
const stageNumber = index + 1;
const isCurrent = index === currentIndex;
const isCompleted = result !== null;
let label = `${stageNumber}번 남은 단계`;
let className =
"block h-2 w-full rounded-full transition";
if (isCompleted) {
label = result.correct
? `${stageNumber}번 완료, 정답`
: `${stageNumber}번 완료, 오답`;
className += result.correct
? " bg-emerald-700"
: " bg-red-700";
} else if (isCurrent) {
label = `${stageNumber}번 현재 단계`;
className += " bg-emerald-300 ring-2 ring-emerald-700";
} else {
className += " bg-slate-200";
}
return (
<li key={stageNumber} className="min-w-0 flex-1">
<span className={className} aria-label={label} title={label} />
</li>
);
})}
</ol>
</div>
);
}
@@ -0,0 +1,123 @@
"use client";
import { useEffect, useRef } from "react";
import KatexRenderer from "@/components/math/KatexRenderer";
import type { VariableTermChoice } from "@/lib/engine/variable-types";
interface VariableChoiceAnswerFormProps {
choices: VariableTermChoice[][];
selectedChoiceIds: (string | null)[];
activeTermIndex: number;
pendingChoiceLabels: string[];
onSelectChoice: (termIndex: number, choiceId: string) => void;
}
export default function VariableChoiceAnswerForm({
choices,
selectedChoiceIds,
activeTermIndex,
pendingChoiceLabels,
onSelectChoice,
}: VariableChoiceAnswerFormProps) {
const firstChoiceRef = useRef<HTMLButtonElement>(null);
const activeChoices = [...choices[activeTermIndex]].sort(
(a, b) => a.coefficient - b.coefficient,
);
useEffect(() => {
firstChoiceRef.current?.focus();
}, [activeTermIndex]);
function getSelectedChoice(index: number) {
const selectedChoiceId = selectedChoiceIds[index];
if (!selectedChoiceId) return null;
return choices[index].find((choice) => choice.id === selectedChoiceId) ?? null;
}
return (
<div className="rounded-md bg-white p-6 shadow-sm ring-1 ring-slate-200">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-500">
{activeTermIndex + 1} .
</p>
</div>
<ol
className="flex min-w-40 items-center gap-1"
aria-label="입력 항 진행 상태"
>
{selectedChoiceIds.map((choiceId, index) => {
const selected = activeTermIndex === index;
const selectedChoice = getSelectedChoice(index);
const completed = choiceId !== null && selectedChoice !== null;
const statusLabel =
completed && selectedChoice
? `선택값 ${selectedChoice.latex}`
: `원래 항 ${pendingChoiceLabels[index]}, 미선택`;
return (
<li
key={index}
className="min-w-0 flex-1"
aria-label={`${index + 1}${statusLabel}${
selected ? ", 현재 선택 중" : ""
}`}
>
<span
className={`block truncate text-center text-xs font-bold ${
selected || completed ? "text-emerald-800" : "text-slate-500"
}`}
>
{completed ? (
<KatexRenderer latex={selectedChoice.latex} />
) : (
<KatexRenderer latex={pendingChoiceLabels[index]} />
)}
</span>
<span
className={`mt-1 block h-1.5 rounded-full ${
completed
? "bg-emerald-700"
: selected
? "bg-emerald-300 ring-1 ring-emerald-700"
: "bg-slate-200"
}`}
/>
</li>
);
})}
</ol>
</div>
<div
className="mt-5 grid gap-3 sm:grid-cols-2"
role="group"
aria-label={`${activeTermIndex + 1}항 보기 선택`}
>
{activeChoices.map((choice, index) => {
const selected = selectedChoiceIds[activeTermIndex] === choice.id;
return (
<button
key={choice.id}
ref={index === 0 ? firstChoiceRef : undefined}
type="button"
onClick={() => onSelectChoice(activeTermIndex, choice.id)}
aria-pressed={selected}
className={`flex h-20 items-center justify-center rounded-md text-3xl font-bold transition focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 ${
selected
? "bg-emerald-700 text-white"
: "bg-slate-50 text-slate-950 ring-1 ring-slate-200 hover:bg-slate-100"
}`}
>
<KatexRenderer latex={choice.latex} />
</button>
);
})}
</div>
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useEffect, useRef } from "react";
import AnswerResultLine from "@/components/game/AnswerResultLine";
import type {
VariableGradeResult,
VariableParenthesisProblem,
} from "@/lib/engine/variable-types";
import { formatVariableTermLatex } from "@/lib/engine/variable-parenthesis";
interface VariableFeedbackPanelProps {
result: VariableGradeResult;
problem: VariableParenthesisProblem;
isLastProblem: boolean;
onNext: () => void;
}
function formatSignedNumber(value: number) {
return value < 0 ? `(${value})` : `${value}`;
}
export default function VariableFeedbackPanel({
result,
problem,
isLastProblem,
onNext,
}: VariableFeedbackPanelProps) {
const nextButtonRef = useRef<HTMLButtonElement>(null);
const signedTerms = problem.terms.map((term, index) =>
formatVariableTermLatex({
...term,
coefficient:
index > 0 && problem.operators[index - 1] === "-"
? -term.coefficient
: term.coefficient,
}),
);
const feedbackItems = signedTerms.map((termLatex, index) => {
const userAnswer = result.userAnswer[index];
const correctAnswer = result.correctAnswer[index];
return {
formulaLatex: `${formatSignedNumber(problem.multiplier)}\\times ${termLatex}`,
userAnswerLatex: userAnswer?.latex ?? "?",
correctAnswerLatex: correctAnswer.latex,
correct: userAnswer?.coefficient === correctAnswer.coefficient,
};
});
useEffect(() => {
nextButtonRef.current?.focus();
}, []);
return (
<div
className={`rounded-md p-6 shadow-sm ring-1 ${
result.correct
? "bg-emerald-50 text-emerald-950 ring-emerald-200"
: "bg-red-50 text-red-950 ring-red-200"
}`}
>
<p className="text-lg font-bold">
{result.correct ? "정답입니다." : "오답입니다."}
</p>
<ul className="mt-5 space-y-3">
{feedbackItems.map((item, index) => (
<AnswerResultLine
key={index}
expressionLatex={item.formulaLatex}
userAnswerLatex={item.userAnswerLatex}
correctAnswerLatex={item.correctAnswerLatex}
correct={item.correct}
/>
))}
</ul>
<button
ref={nextButtonRef}
type="button"
onClick={onNext}
className="mt-5 inline-flex h-11 items-center justify-center rounded-md bg-slate-950 px-5 text-base font-semibold text-white transition hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2"
>
{isLastProblem ? "결과 보기" : "다음 문제"}
</button>
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
"use client";
import Link from "next/link";
import { useEffect, useRef } from "react";
import AnswerResultLine from "@/components/game/AnswerResultLine";
import KatexRenderer from "@/components/math/KatexRenderer";
import type {
DistributedVariableTerm,
VariableGradeResult,
VariableParenthesisProblem,
} from "@/lib/engine/variable-types";
import { formatVariableExpressionLatex } from "@/lib/engine/variable-parenthesis";
interface VariableScoreSummaryProps {
correctCount: number;
totalCount: number;
problems: VariableParenthesisProblem[];
results: (VariableGradeResult | null)[];
onRestart: () => void;
}
function renderTermPair(
terms: (DistributedVariableTerm | null)[],
comparisonTerms: (DistributedVariableTerm | null)[],
mode: "user" | "correct",
) {
return (
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
{terms.map((term, index) => {
if (!term) {
return (
<span key={index} className="text-red-700">
?
</span>
);
}
const correct = term.coefficient === comparisonTerms[index]?.coefficient;
const className =
mode === "user"
? correct
? undefined
: "text-red-700"
: correct
? undefined
: "font-bold text-blue-700";
const operator = term.coefficient < 0 ? "-" : "+";
const displayLatex =
index === 0 ? term.latex : `${operator} ${term.latex.replace(/^-/, "")}`;
return (
<span key={index} className={className}>
<KatexRenderer latex={displayLatex} output="mathml" />
</span>
);
})}
</span>
);
}
export default function VariableScoreSummary({
correctCount,
totalCount,
problems,
results,
onRestart,
}: VariableScoreSummaryProps) {
const restartButtonRef = useRef<HTMLButtonElement>(null);
const solvedItems = problems
.map((problem, index) => ({
problem,
result: results[index],
}))
.filter(
(
item,
): item is {
problem: VariableParenthesisProblem;
result: VariableGradeResult;
} => item.result !== null,
);
useEffect(() => {
restartButtonRef.current?.focus();
}, []);
return (
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8">
<div className="space-y-3">
<p className="text-sm font-semibold text-emerald-700">
</p>
<h1 className="text-4xl font-bold tracking-normal sm:text-5xl">
</h1>
<p className="text-xl font-semibold text-slate-800">
{correctCount} / {totalCount}
</p>
</div>
<ol className="space-y-3">
{solvedItems.map(({ problem, result }, index) => (
<AnswerResultLine
key={problem.key}
expressionLatex={problem.latexBefore}
userAnswerLatex={formatVariableExpressionLatex([
...result.correctAnswer.map(
(term, termIndex) => result.userAnswer[termIndex] ?? term,
),
])}
correctAnswerLatex={problem.latexAfter}
correct={result.correct}
userAnswerContent={renderTermPair(
result.userAnswer,
result.correctAnswer,
"user",
)}
correctAnswerContent={renderTermPair(
result.correctAnswer,
result.userAnswer,
"correct",
)}
prefix={
<span className="mr-1 text-sm font-bold text-slate-500">
{index + 1}.
</span>
}
/>
))}
</ol>
<div className="flex flex-wrap gap-3">
<button
ref={restartButtonRef}
type="button"
onClick={onRestart}
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"
>
</button>
<Link
href="/"
className="inline-flex h-12 items-center justify-center rounded-md border border-slate-300 px-6 text-base font-semibold text-slate-800 transition hover:bg-white focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
>
</Link>
</div>
</section>
</main>
);
}
+24
View File
@@ -0,0 +1,24 @@
"use client";
import { renderToString } from "katex";
interface KatexRendererProps {
latex: string;
displayMode?: boolean;
output?: "htmlAndMathml" | "mathml";
}
export default function KatexRenderer({
latex,
displayMode = false,
output = "htmlAndMathml",
}: KatexRendererProps) {
const html = renderToString(latex, {
displayMode,
output,
throwOnError: false,
});
const Tag = displayMode ? "div" : "span";
return <Tag dangerouslySetInnerHTML={{ __html: html }} />;
}
+20
View File
@@ -0,0 +1,20 @@
services:
math-quiz:
container_name: math-quiz
build:
context: .
dockerfile: Dockerfile
image: math-quiz:latest
restart: unless-stopped
env_file:
- .env.production
environment:
NODE_ENV: production
PORT: 3000
HOSTNAME: 0.0.0.0
networks:
- proxy-network
networks:
proxy-network:
external: true
+148
View File
@@ -0,0 +1,148 @@
# Synology NAS Docker Compose 배포
이 문서는 현재 프로젝트를 Synology NAS에서 `math-quiz` 컨테이너로 실행하고, Nginx Proxy Manager(NPM)에서 `math-quiz.jisangs.com`으로 연결하는 절차입니다.
## 전제
- NAS에 Docker 또는 Container Manager가 설치되어 있어야 합니다.
- NPM 컨테이너가 80/443 포트를 사용하고 있어야 합니다.
- `math-quiz.jisangs.com` DNS가 NAS로 연결되어 있어야 합니다.
- NPM 컨테이너와 `math-quiz` 컨테이너가 같은 Docker 네트워크에 있어야 합니다.
## 1. NPM 공용 네트워크 준비
NAS SSH에서 한 번만 실행합니다.
```bash
docker network create proxy-network
```
이미 같은 이름의 네트워크가 있으면 에러가 나도 괜찮습니다.
NPM 컨테이너가 이미 실행 중이라면 같은 네트워크에 붙입니다.
```bash
docker network connect proxy-network <npm-container-name>
```
예를 들어 NPM 컨테이너 이름이 `nginx-proxy-manager`라면:
```bash
docker network connect proxy-network nginx-proxy-manager
```
## 2. 프로젝트 파일 업로드
NAS에 배포 디렉터리를 만듭니다.
```bash
mkdir -p /volume1/docker/math-quiz
```
로컬 작업 폴더에서 NAS로 프로젝트를 복사합니다.
```bash
rsync -av --delete \
--exclude .git \
--exclude node_modules \
--exclude .next \
--exclude .pnpm-store \
./ <nas-user>@<nas-host>:/volume1/docker/math-quiz/
```
Git을 NAS에서 직접 사용할 수 있다면 아래 방식도 가능합니다.
```bash
cd /volume1/docker
git clone <repo-url> math-quiz
```
이미 clone 되어 있다면:
```bash
cd /volume1/docker/math-quiz
git pull
```
## 3. 운영 환경변수 작성
NAS의 `/volume1/docker/math-quiz/.env.production` 파일을 만듭니다.
```bash
cd /volume1/docker/math-quiz
cp .env.production.example .env.production
vi .env.production
```
현재 구현은 DB/Redis/NextAuth를 아직 사용하지 않지만, 이후 기능 추가를 위해 키는 준비해 둡니다.
```env
DATABASE_URL=
REDIS_URL=
NEXTAUTH_SECRET=
NEXTAUTH_URL=https://math-quiz.jisangs.com
```
`NEXTAUTH_SECRET`을 사용할 때는 아래처럼 생성할 수 있습니다.
```bash
openssl rand -base64 32
```
## 4. Docker 이미지 빌드 및 실행
```bash
cd /volume1/docker/math-quiz
docker compose up -d --build
```
상태 확인:
```bash
docker compose ps
docker logs -f math-quiz
```
정상 실행되면 컨테이너 내부에서 Next.js 서버가 `3000` 포트로 떠 있습니다. 외부 80/443은 NPM이 담당하므로 `docker-compose.yml`에는 호스트 포트 노출을 두지 않았습니다.
## 5. Nginx Proxy Manager 설정
NPM 관리자 화면에서 Proxy Host를 추가합니다.
- Domain Names: `math-quiz.jisangs.com`
- Scheme: `http`
- Forward Hostname / IP: `math-quiz`
- Forward Port: `3000`
- Websockets Support: 필요 시 활성화
- SSL: Let's Encrypt 인증서 발급
- Force SSL: 활성화
NPM 컨테이너와 `math-quiz` 컨테이너가 `proxy-network` 네트워크를 공유해야 `Forward Hostname``math-quiz`를 사용할 수 있습니다.
## 6. 업데이트 반영
로컬에서 변경한 파일을 다시 rsync 하거나 NAS에서 `git pull` 후 재빌드합니다.
```bash
cd /volume1/docker/math-quiz
docker compose up -d --build
docker image prune -f
```
로그 확인:
```bash
docker logs -f math-quiz
```
## 참고: 네트워크 이름이 다를 때
NPM에서 이미 쓰는 Docker 네트워크 이름이 있다면 `docker-compose.yml``proxy-network`를 해당 네트워크 이름으로 바꿉니다.
```yaml
networks:
proxy-network:
external: true
```
위 블록의 `proxy-network` 이름과 서비스의 `networks` 값을 같은 이름으로 맞추면 됩니다.
+358
View File
@@ -0,0 +1,358 @@
# 02. 괄호 앞 음수 분배 — 간단한 풀이 화면
## 계획 평가
Claude Code가 작성한 기존 계획은 방향이 맞다. `01-project-start.md`에서 만든 `/` -> `/play` 흐름 위에 문제 생성 엔진, KaTeX 표시, 답 입력, 채점, 10문제 세션을 붙이는 순서가 자연스럽다.
다만 실제 구현 단계로 쓰기에는 몇 가지를 조정하는 편이 좋다.
- 엔진 단위 테스트만 하는 단계에서는 `@vitejs/plugin-react`, `jsdom`, `@testing-library/react`가 아직 필요하지 않다.
- `generateSession()`의 중복 방지는 랜덤 `id`가 아니라 `multiplier/a/operator/b` 조합 기준이어야 한다.
- Zustand는 한 문제 풀이가 동작한 뒤 도입한다. 이 순서를 지키면 버그가 엔진, UI, 상태 관리 중 어디에서 생겼는지 분리하기 쉽다.
- `PlayClient`에서 세션을 초기화할 때 렌더링 중 `initSession()`을 직접 호출하지 않고 `useEffect`에서 호출해야 한다.
- 이번 문서는 “간단한 풀이 화면”이 목표이므로 Framer Motion, 정교한 진행 바, 배너 디자인, E2E는 다음 단계로 넘긴다.
---
## 목표
`/play` 화면에서 **괄호 앞 음수 분배** 문제를 10개 풀고 점수를 확인할 수 있게 만든다.
이번 단계의 최종 상태는 다음과 같다.
- 자체 알고리즘으로 괄호 앞 음수 분배 문제를 생성한다.
- 문제와 정답 수식을 KaTeX로 렌더링한다.
- 사용자가 두 항의 답을 입력하고 채점 결과를 확인한다.
- 10문제 풀이가 끝나면 점수 요약을 보여준다.
---
## 이번 단계에서 할 것
- `lib/engine/`에 문제 생성·채점 순수 함수와 Vitest 단위 테스트 추가
- `components/math/`에 KaTeX 렌더링 컴포넌트 추가
- `/play`에 한 문제 풀이 UI 구현
- 한 문제 풀이 확인 후 `store/gameStore.ts`로 10문제 세션 구현
- 세션 완료 후 간단한 점수 요약 표시
## 이번 단계에서 하지 않을 것
- Framer Motion 애니메이션
- 정교한 ProgressBar, FeedbackBanner, ScoreSummary 디자인 컴포넌트화
- DB, Prisma, Redis, NextAuth
- 유형 선택 화면, 모드 선택 화면
- 스피드 퀴즈 모드
- 미지수 `x`, `y`, `z`가 포함된 문제
- Playwright E2E 테스트
---
## 의존성
필요한 시점에 나누어 설치한다.
### 엔진 테스트
```bash
pnpm add -D vitest
```
### 수식 렌더링과 세션 상태
```bash
pnpm add katex react-katex zustand
pnpm add -D @types/katex
```
React 컴포넌트 테스트를 추가하지 않는 한 `@testing-library/react`, `jsdom`, `@vitejs/plugin-react`는 이번 단계에 넣지 않는다.
---
## 생성 또는 수정 파일
### 신규 생성
```text
lib/engine/types.ts
lib/engine/parenthesis-sign.ts
lib/engine/parenthesis-sign.test.ts
vitest.config.ts
components/math/KatexRenderer.tsx
store/gameStore.ts
app/(game)/play/PlayClient.tsx
```
### 수정
```text
package.json # test script 추가
app/layout.tsx # KaTeX CSS import 추가
app/(game)/play/page.tsx # PlayClient 마운트
```
---
## 작업 순서
### 2-A. 문제 생성 엔진
완료 기준: `pnpm test` 통과
`lib/engine/types.ts`
```ts
export interface ParenthesisSignProblem {
id: string
key: string
multiplier: number
a: number
operator: '+' | '-'
b: number
distributedA: number
distributedB: number
latexBefore: string
latexAfter: string
}
export interface UserAnswer {
a: number
b: number
}
export interface GradeResult {
correct: boolean
correctAnswer: [number, number]
}
```
`lib/engine/parenthesis-sign.ts`에 구현할 함수:
- `generateProblem(): ParenthesisSignProblem`
- `multiplier`: `-9`부터 `-1` 사이 정수
- `a`, `b`: `1`부터 `9` 사이 정수
- `a !== b`
- `operator`: `'+' | '-'`
- `distributedA = multiplier * a`
- `distributedB = operator === '-' ? multiplier * -b : multiplier * b`
- `key``multiplier:a:operator:b` 형식으로 만든다.
- `latexBefore`: 예시 `-2(3 - 6)`
- `latexAfter`: 예시 `-6 + 12`
- `generateSession(count = 10): ParenthesisSignProblem[]`
- `count``1` 이상인지 확인한다.
- 가능한 문제 수는 `9 * 8 * 2 = 144`개이므로 `count > 144`이면 에러를 던진다.
- 중복 방지는 `id`가 아니라 `key` 기준으로 한다.
- `gradeAnswer(problem, answer): GradeResult`
- 두 항이 모두 일치할 때만 정답이다.
- 입력 순서를 바꾸어 쓴 답은 오답으로 처리한다.
`lib/engine/parenthesis-sign.test.ts` 검증 항목:
- `multiplier`는 항상 음수 정수다.
- `a`, `b`는 범위 안에 있고 서로 다르다.
- `distributedA`, `distributedB` 계산이 `operator`별로 맞다.
- `gradeAnswer()`는 정답과 오답을 정확히 구분한다.
- `generateSession(10)`은 10개 문제를 만들고 `key` 중복이 없다.
- `generateSession(145)`는 에러를 던진다.
`vitest.config.ts`
```ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
},
})
```
`package.json`
```json
"test": "vitest run"
```
---
### 2-B. KaTeX 렌더링
완료 기준: 브라우저에서 문제 수식이 KaTeX로 표시됨
`app/layout.tsx`에 전역 CSS를 추가한다.
```ts
import 'katex/dist/katex.min.css'
```
`components/math/KatexRenderer.tsx`
```tsx
'use client'
import { BlockMath, InlineMath } from 'react-katex'
interface KatexRendererProps {
latex: string
displayMode?: boolean
}
export default function KatexRenderer({
latex,
displayMode = false,
}: KatexRendererProps) {
return displayMode ? <BlockMath math={latex} /> : <InlineMath math={latex} />
}
```
이 단계에서는 `/play`에 임시 문제 하나를 표시해서 KaTeX 렌더링만 확인한다.
---
### 2-C. 한 문제 풀이 UI
완료 기준: 한 문제를 풀고 정답·오답 피드백이 표시됨
`app/(game)/play/PlayClient.tsx`를 클라이언트 컴포넌트로 만든다.
```tsx
'use client'
```
구성:
- 문제 수식: `KatexRenderer``problem.latexBefore` 표시
- 답 입력: 숫자 입력 두 칸
- 제출 버튼
- Enter 제출
- 정답이면 간단한 성공 문구
- 오답이면 정답 수식과 정답 두 항 표시
- 새 문제 버튼
로컬 상태:
```ts
const [problem, setProblem] = useState<ParenthesisSignProblem>(() => generateProblem())
const [answerA, setAnswerA] = useState('')
const [answerB, setAnswerB] = useState('')
const [result, setResult] = useState<GradeResult | null>(null)
```
입력 처리:
- 빈 문자열은 제출하지 않는다.
- `Number.parseInt(value, 10)` 결과가 `NaN`이면 제출하지 않는다.
- 채점 후에는 같은 문제에서 중복 제출하지 않도록 제출 버튼을 비활성화한다.
이 단계에서는 Zustand를 사용하지 않는다.
---
### 2-D. 10문제 세션
완료 기준: 10문제를 모두 풀면 점수 요약 화면이 표시됨
`store/gameStore.ts`
```ts
interface GameSession {
problems: ParenthesisSignProblem[]
currentIndex: number
results: (GradeResult | null)[]
}
interface GameState {
session: GameSession | null
initSession: (count?: number) => void
submitAnswer: (answer: UserAnswer) => void
nextProblem: () => void
resetSession: () => void
currentProblem: () => ParenthesisSignProblem | null
currentResult: () => GradeResult | null
isComplete: () => boolean
score: () => { correct: number; total: number }
}
```
상태 규칙:
- `initSession(10)`은 10개 문제와 `null` 결과 배열을 만든다.
- `submitAnswer()`는 현재 문제의 결과가 `null`일 때만 채점한다.
- `nextProblem()`은 채점 후에만 다음 문제로 이동한다.
- 마지막 문제에서 `nextProblem()`을 호출하면 완료 상태가 된다.
- `resetSession()`은 기존 세션을 버리고 새 세션을 시작한다.
`PlayClient.tsx` 전환:
- `useEffect`에서 `session === null`이면 `initSession()` 호출
- `currentProblem() === null`이면 로딩 또는 완료 화면 표시
- 풀이 중에는 현재 문제 번호를 텍스트로 표시한다. 예: `3 / 10`
- 세션 완료 후 맞은 개수와 전체 개수를 표시한다.
- 다시 시작 버튼은 `resetSession()`을 호출한다.
`app/(game)/play/page.tsx`
```tsx
import PlayClient from './PlayClient'
export default function PlayPage() {
return <PlayClient />
}
```
---
## 화면 흐름
```text
/ 시작 화면
- "연습 시작" 클릭
/play 풀이 화면
├─ 현재 문제 번호
├─ 문제 수식 (KaTeX)
├─ 답 입력 두 칸
├─ 제출 버튼
├─ 정답/오답 피드백
├─ 다음 문제 버튼
└─ 10문제 완료 후 점수 요약
- n / 10 정답
- 다시 시작
```
---
## 완료 기준
- `pnpm test` 통과
- `pnpm lint` 통과
- 브라우저에서 `/play` 수식이 KaTeX로 렌더링된다.
- 정답 입력 시 정답 피드백이 표시된다.
- 오답 입력 시 오답 피드백과 정답이 표시된다.
- 10문제를 모두 풀면 점수 요약 화면이 표시된다.
- 다시 시작을 누르면 새 10문제 세션이 시작된다.
## 확인 명령
```bash
pnpm test
pnpm lint
pnpm dev
```
브라우저 확인은 `pnpm dev` 실행 후 `http://localhost:3000/play`에서 한다.
---
## 다음 단계
`03-ux-polish.md`에서 UI 품질을 높인다.
- 진행률 바
- 정답·오답 배너 컴포넌트
- 점수 요약 컴포넌트
- Framer Motion 기반 전환 애니메이션
- 키보드 탐색과 포커스 처리 개선
+246
View File
@@ -0,0 +1,246 @@
# 03. 학습 내용 선택과 숫자 괄호 준비 화면
## 계획 평가
Codex가 작성한 기존 계획은 방향이 맞다. `StartControls.tsx` 이동, `/number-parentheses` 라우트 신설, 첫 화면을 학습 내용 선택으로 전환하는 흐름이 현재 코드 구조와 잘 맞는다.
다만 실제 코드 상태와 맞추어 조정할 부분이 있다.
- `PlayClient`는 URL `?count=N`으로 문제 수를 받고, `gameStore``selectedProblemCount``setProblemCount`가 이미 구현되어 있다. 준비 화면에서 이 흐름을 그대로 이어받으면 된다.
- 괄호 종류 선택은 이번 단계에서 UI만 추가한다. `()`만 선택 가능하고 `{}`, `[]`는 disabled로 표시한다. 괄호 종류를 Zustand나 URL에 저장하는 것은 실제 문제 생성이 지원할 때로 미룬다.
- `app/StartControls.tsx`는 준비 화면으로 내용을 옮긴 뒤 삭제한다.
- 문제 풀이 화면(`PlayClient.tsx`, `play/page.tsx`)은 이번 단계에서 수정하지 않는다.
---
## 목표
앞으로 학습 내용을 계속 추가할 수 있도록 첫 화면을 학습 내용 선택 화면으로 바꾸고, 현재 숫자 괄호 풀이 시작 기능을 별도 준비 화면으로 분리한다.
이번 단계의 최종 상태는 다음과 같다.
- `/`에서 학습 내용을 선택한다.
- `숫자 괄호`를 선택하면 문제 수와 괄호 종류를 고르는 준비 화면으로 이동한다.
- 분수 괄호, 실수 괄호, 미지수 괄호는 버튼만 표시하고 비활성화한다.
- `/play` 문제 풀이 화면과 풀이 로직은 현재 동작 그대로 유지한다.
---
## 이번 단계에서 할 것
- `app/page.tsx`를 학습 내용 선택 화면으로 교체
- `app/(game)/number-parentheses/page.tsx`, `NumberParenthesesClient.tsx` 신규 생성
- `StartControls.tsx`의 문제 수 선택과 연습 시작 흐름을 준비 화면으로 이동
- 준비 화면에 괄호 종류 버튼 그룹 추가 (`()` 선택 가능, `{}` / `[]` disabled)
- `app/StartControls.tsx` 삭제
## 이번 단계에서 하지 않을 것
- 분수 괄호, 실수 괄호, 미지수 괄호 문제 생성
- `{}`, `[]` 괄호 문제 생성
- 괄호 종류를 Zustand나 URL에 저장
- 문제 풀이 화면 UI 수정
- 새로운 게임 모드 추가
- DB, Redis, NextAuth
---
## 생성 또는 수정 파일
### 신규 생성
```text
app/(game)/number-parentheses/page.tsx
app/(game)/number-parentheses/NumberParenthesesClient.tsx
```
### 수정
```text
app/page.tsx
```
### 삭제
```text
app/StartControls.tsx
```
### 변경 없음
```text
app/(game)/play/page.tsx
app/(game)/play/PlayClient.tsx
store/gameStore.ts
lib/engine/
components/
```
---
## 작업 순서
### 3-A. 숫자 괄호 준비 화면 신설
완료 기준: `/number-parentheses`에서 문제 수를 고르고 연습을 시작할 수 있다.
`app/(game)/number-parentheses/page.tsx`는 Server Component 래퍼로 만든다.
```tsx
import NumberParenthesesClient from './NumberParenthesesClient'
export default function NumberParenthesesPage() {
return <NumberParenthesesClient />
}
```
`app/(game)/number-parentheses/NumberParenthesesClient.tsx`에는 `'use client'`를 붙이고, 현재 `StartControls.tsx`의 내용을 기반으로 구성한다.
**문제 수 버튼 그룹**
현재 `StartControls.tsx`의 문제 수 선택 로직을 그대로 가져온다.
- `useGameStore`에서 `selectedProblemCount`, `setProblemCount`, `initSession`을 가져온다.
- `5`, `10` 버튼 그룹 (기본값 `10`)
- 선택된 문제 수는 `selectedProblemCount`로 표시한다.
**괄호 종류 버튼 그룹**
```tsx
type BracketKind = 'round' | 'curly' | 'square'
const brackets: { kind: BracketKind; label: string; disabled: boolean }[] = [
{ kind: 'round', label: '( )', disabled: false },
{ kind: 'curly', label: '{ }', disabled: true },
{ kind: 'square', label: '[ ]', disabled: true },
]
```
- `round`는 항상 selected 상태로 고정 표시한다.
- `curly`, `square`는 disabled 스타일을 적용하고 클릭을 막는다.
- 괄호 종류 상태를 useState나 Zustand에 저장하지 않는다. 이번 단계에서는 `()` 하나만 유효하다.
**연습 시작 버튼**
현재 `StartControls.tsx`의 Link를 그대로 가져온다.
```tsx
<Link
href={`/play?count=${selectedProblemCount}`}
onClick={() => initSession(selectedProblemCount)}
>
</Link>
```
**뒤로 가기 링크**
준비 화면에서 첫 화면으로 돌아갈 수 있도록 "처음으로" 링크를 추가한다.
```tsx
<Link href="/"></Link>
```
---
### 3-B. 첫 화면을 학습 내용 선택으로 교체
완료 기준: `/`에서 네 가지 학습 내용 버튼이 표시된다.
`app/page.tsx`를 수정한다.
- 제목과 안내 문구를 여러 학습 내용을 선택하는 맥락으로 바꾼다.
- `StartControls` 호출을 제거한다.
- 학습 내용 목록을 렌더링한다.
버튼 목록:
| 학습 내용 | 상태 | 이동 |
|---|---|---|
| 숫자 괄호 | 활성 | `/number-parentheses` |
| 분수 괄호 | disabled | — |
| 실수 괄호 | disabled | — |
| 미지수 괄호 | disabled | — |
활성 버튼은 `Link`로, disabled 버튼은 `<button disabled>`으로 만든다.
클라이언트 기능이 없으므로 `page.tsx` 자체에 인라인으로 구성하고 별도 컴포넌트로 분리하지 않는다.
---
### 3-C. StartControls.tsx 삭제 및 회귀 확인
완료 기준: `숫자 괄호`부터 결과 화면까지 기존 풀이가 동작한다.
`app/StartControls.tsx`를 삭제한다.
삭제 전에 `StartControls`를 참조하는 파일이 없는지 확인한다.
```bash
grep -r "StartControls" /Users/jisangs/work/jinaju/git/web/math-quiz/app
```
풀이 흐름을 처음부터 끝까지 수동으로 확인한다.
1. `/` 접속 → 학습 내용 선택 화면 표시 확인
2. `숫자 괄호` 클릭 → `/number-parentheses` 이동 확인
3. 문제 수 `5` 선택 → `연습 시작` 클릭 → `/play?count=5` 이동 확인
4. 5문제 세션 완료 → 점수 요약 표시 확인
5. `다시 시작` 클릭 → 새 세션 확인
6. `/number-parentheses`로 돌아와 문제 수 `10` 선택 → `/play?count=10` 이동 확인
---
## 화면 흐름
```text
/ 학습 내용 선택
- [숫자 괄호 풀기] → /number-parentheses
- [분수 괄호 풀기] disabled
- [실수 괄호 풀기] disabled
- [미지수 괄호 풀기] disabled
/number-parentheses 숫자 괄호 준비
- 문제 수: [5] [10]
- 괄호 종류: [( )] [{} disabled] [[] disabled]
- [연습 시작] → /play?count=N
- [처음으로] → /
/play 문제 풀이 (현재 동작 유지)
```
---
## 완료 기준
- `/`가 학습 내용 선택 화면으로 표시된다.
- `숫자 괄호` 버튼으로 `/number-parentheses`에 진입할 수 있다.
- `분수 괄호`, `실수 괄호`, `미지수 괄호`는 disabled 상태다.
- 준비 화면에서 문제 수 `5`, `10`을 선택할 수 있다.
- 준비 화면에서 `()` 괄호 종류가 선택 상태로 표시된다.
- `{}`, `[]` 괄호 종류는 disabled 상태다.
- `연습 시작`으로 기존 `/play` 문제 풀이 화면에 진입한다.
- `/play`의 문제 풀이 동작은 기존과 동일하다.
- `pnpm build` 통과
- `pnpm test` 통과
## 확인 명령
```bash
pnpm build
pnpm test
pnpm dev
```
---
## 다음 단계
새 학습 내용(분수 괄호, 실수 괄호, 미지수 괄호)을 추가할 때는 다음 순서로 확장한다.
1. `lib/engine/`에 해당 유형의 문제 생성 함수 추가
2. `app/(game)/number-parentheses/NumberParenthesesClient.tsx`의 괄호 종류 버튼에서 disabled 해제
3. 괄호 종류를 Zustand나 URL 파라미터로 저장하고 `initSession`에 파라미터 연결
+413
View File
@@ -0,0 +1,413 @@
# 04. 미지수 괄호 학습과 선택지형 풀이 화면
## 계획 검토
Codex가 작성한 초안은 방향이 맞다. 엔진 설계, store 분리, 화면 구조가 기존 숫자 괄호 흐름과 일관성 있게 정리되어 있다.
조정이 필요한 부분:
- `VariableUserAnswer``selectedChoiceIds`는 보기 id 기반 채점이므로 그대로 유지한다. 보기가 매번 섞이므로 계수 값 자체보다 id로 선택을 추적하는 것이 명확하다.
- 사용자가 요청한 "보기 하나는 음수, 보기 하나는 양수" 방식이 Codex 초안과 동일하다. 보기는 정답 계수의 절대값을 기준으로 양수/음수 두 개만 표시한다.
- 선택지 순서는 매번 셔플한다.
- 둘째 항 선택 후 `정답 확인` 버튼으로 명시적으로 제출한다. 자동 제출은 사용자가 선택을 확인할 여지 없이 넘어가므로 사용하지 않는다.
- 준비 화면은 `NumberParenthesesClient`와 구조가 동일하므로 처음에는 별도 파일로 구현한다. 중복이 불편해지면 그때 공통 컴포넌트로 분리한다.
---
## 목표
미지수가 포함된 괄호 풀기 문제를 선택지 방식으로 풀 수 있는 학습 흐름을 추가한다.
이번 단계의 최종 상태:
- `/`에서 `미지수 괄호 풀기`가 활성 링크로 표시된다.
- `/variable-parentheses` 준비 화면에서 문제 수와 괄호 종류를 선택한다.
- `/variable-parentheses/play?count=N` 풀이 화면에서 첫째 항, 둘째 항 순서로 보기를 고르고 정답을 확인한다.
- 기존 숫자 괄호 풀이 화면은 현재 동작을 유지한다.
---
## 이번 단계에서 할 것
- 첫 화면의 `미지수 괄호 풀기` disabled 해제 (`/variable-parentheses` 링크)
- 미지수 괄호 학습 준비 화면 추가
- 미지수 괄호 문제 생성 엔진 추가
- 미지수 괄호 전용 Zustand store 추가
- 미지수 괄호 전용 풀이 화면 추가 (선택지형)
- 정답·오답 피드백 및 결과 화면 추가
- 엔진 단위 테스트 추가
## 이번 단계에서 하지 않을 것
- 분수 괄호, 실수 괄호 문제 생성
- `{}`, `[]` 괄호 문제 생성
- 여러 미지수(`x, y, z`) 지원
- 기존 숫자 괄호 `/play` 화면 동작 변경
- 사용자가 직접 미지수 답을 타이핑하는 입력 방식
- DB, Redis, NextAuth 연동
- 스피드 퀴즈 모드
---
## 문제 생성 규칙
### 문제 형태
```text
multiplier(a ± b)
```
괄호 안 두 항 중 정확히 하나가 `정수 × x` 항이다.
예시:
```text
-2(3x - 6) → -6x + 12
-5(4 + 7x) → -20 - 35x
-3(8x + 2) → -24x - 6
```
### 생성 조건
| 항목 | 범위 |
|---|---|
| `multiplier` | `-9`~`-1` 정수 |
| 숫자 항 계수 | `1`~`9` 정수 |
| 미지수 항 계수 | `1`~`9` 정수 |
| 미지수 | `x` 고정 |
| 연산자 | `+` 또는 `-` |
| 미지수 항 위치 | 첫째 항 또는 둘째 항 (한 문제에 하나만) |
두 항 계수가 같아도 허용한다 (숫자 괄호와 달리 항의 종류가 다르므로 혼동이 없다).
### 정답 계산
- 첫째 항 정답: `multiplier × firstTerm.coefficient` (미지수 항이면 `x` 붙임)
- 둘째 항 정답: `multiplier × (operator === '-' ? -secondTerm.coefficient : secondTerm.coefficient)` (미지수 항이면 `x` 붙임)
### 보기 생성
정답 계수의 절대값을 기준으로 두 보기를 만든다.
```text
정답: -6x → 보기: -6x, 6x
정답: 12 → 보기: -12, 12
```
- 보기 두 개는 항 종류(미지수 항 / 숫자 항)가 같다.
- 보기 순서는 매번 셔플한다.
- 보기 `id`는 안정적으로 생성한다: `${problem.key}:term-0:negative`, `${problem.key}:term-0:positive`
### 중복 방지 key
```text
${multiplier}:${firstKind}:${firstCoeff}:${operator}:${secondKind}:${secondCoeff}
```
예: `-2:variable:3:-:constant:6`
---
## 타입 설계
신규 파일: `lib/engine/variable-types.ts`
```ts
export type VariableTermKind = 'constant' | 'variable'
export interface VariableParenthesisTerm {
kind: VariableTermKind
coefficient: number
}
export interface DistributedVariableTerm {
kind: VariableTermKind
coefficient: number
latex: string
}
export interface VariableTermChoice {
id: string
coefficient: number
kind: VariableTermKind
latex: string
}
export interface VariableParenthesisProblem {
id: string
key: string
multiplier: number
operator: '+' | '-'
firstTerm: VariableParenthesisTerm
secondTerm: VariableParenthesisTerm
correctTerms: [DistributedVariableTerm, DistributedVariableTerm]
choices: [VariableTermChoice[], VariableTermChoice[]]
latexBefore: string
latexAfter: string
}
export interface VariableUserAnswer {
selectedChoiceIds: [string, string]
}
export interface VariableGradeResult {
correct: boolean
firstCorrect: boolean
secondCorrect: boolean
userChoiceIds: [string, string]
correctTerms: [DistributedVariableTerm, DistributedVariableTerm]
}
```
---
## 엔진 설계
신규 파일: `lib/engine/variable-parenthesis.ts`
구현 함수:
- `generateVariableParenthesisSession(count?: number): VariableParenthesisProblem[]`
- `gradeVariableParenthesisAnswer(problem, answer): VariableGradeResult`
내부 헬퍼:
- `formatVariableTermLatex(kind, coefficient): string`
- `kind === 'variable'`: `6x`, `-6x`
- `kind === 'constant'`: `6`, `-6`
- `makeChoices(termIndex, key, distributedTerm): VariableTermChoice[]`
- 양수/음수 두 보기를 만들고 셔플
- `createAllVariableSeeds()`: 전체 가능 시드 목록 (최초 1회 생성)
- `shuffle()`: 기존 숫자 괄호 엔진과 동일한 방식
세션 생성 방식은 기존 숫자 괄호 엔진과 동일하게 전체 시드 셔플 후 슬라이스로 중복 방지한다.
---
## 상태 관리 설계
신규 파일: `store/variableGameStore.ts`
기존 `gameStore.ts`와 동일한 구조로 분리한다.
```ts
interface VariableGameState {
session: VariableGameSession | null
selectedProblemCount: ProblemCount
setProblemCount: (count: ProblemCount) => void
initSession: (count?: ProblemCount) => void
submitAnswer: (answer: VariableUserAnswer) => void
nextProblem: () => void
resetSession: () => void
}
```
주의:
- selector 안에서 새 객체·배열을 반환하지 않는다.
- 현재 선택 중인 보기 상태(`selectedChoiceIds`, `activeTermIndex`)는 `VariablePlayClient` 로컬 state로 관리한다.
---
## 화면 설계
### 첫 화면 (`app/page.tsx`)
- `미지수 괄호 풀기` disabled 해제, `href="/variable-parentheses"` 추가
- 분수 괄호, 실수 괄호는 계속 disabled 유지
- 안내 문구를 `숫자 괄호`, `미지수 괄호` 두 개가 활성화된 상태로 수정
### 미지수 괄호 학습 준비 화면
신규 파일:
```text
app/(game)/variable-parentheses/page.tsx
app/(game)/variable-parentheses/VariableParenthesesClient.tsx
```
화면 구성:
- 문제 수: `5`, `10` 버튼 그룹 (기본값 `10`)
- 괄호 종류: `()` selected, `{}` disabled, `[]` disabled
- `연습 시작` 버튼 → `/variable-parentheses/play?count=${selectedProblemCount}` 이동, 클릭 시 세션 초기화
### 미지수 괄호 풀이 화면
신규 파일:
```text
app/(game)/variable-parentheses/play/page.tsx
app/(game)/variable-parentheses/play/VariablePlayClient.tsx
components/game/VariableChoiceAnswerForm.tsx
components/game/VariableFeedbackPanel.tsx
components/game/VariableScoreSummary.tsx
```
풀이 흐름:
```text
문제 표시 (KaTeX 카드)
첫째 항 보기 2개 표시 → 선택
둘째 항 보기 2개 표시 → 선택
[정답 확인] 버튼 활성화 → 클릭
피드백 표시 → [다음 문제] 또는 [결과 보기]
최종 결과 화면
```
`VariableChoiceAnswerForm` props 초안:
```ts
interface VariableChoiceAnswerFormProps {
choices: [VariableTermChoice[], VariableTermChoice[]]
selectedChoiceIds: [string | null, string | null]
activeTermIndex: 0 | 1
onSelectChoice: (termIndex: 0 | 1, choiceId: string) => void
onSubmit: () => void
}
```
접근성:
- 첫째 항 선택 후 둘째 항 첫 번째 보기 버튼에 focus 이동
- 두 항 모두 선택되면 `정답 확인` 버튼 활성화
- 피드백 표시 시 `다음 문제` 버튼에 focus 이동
- 결과 화면 표시 시 `다시 시작` 버튼에 focus 이동
`VariableFeedbackPanel` 표시 형식:
```text
(-2) × 3x = -6x ○
(-2) × (-6) = 12 ○
```
`VariableScoreSummary` 표시 형식:
```text
-2(3x - 6) = -6x + 12 ○
```
기존 `AnswerResultLine` 컴포넌트를 재사용한다. 항 표현이 복잡해지는 경우 `userAnswerContent` / `correctAnswerContent` prop으로 커스텀 렌더링한다.
---
## 작업 순서
### 4-A. 미지수 괄호 엔진 추가
완료 기준: `pnpm test` 통과
- `lib/engine/variable-types.ts` 타입 정의
- `lib/engine/variable-parenthesis.ts` 엔진 구현
- `lib/engine/variable-parenthesis.test.ts` 단위 테스트
테스트 항목:
- 괄호 안 두 항 중 정확히 하나만 미지수 항이다
- 계수는 모두 정수다
- 정답 분배 계산이 맞다
- 둘째 항이 `-` 뒤에 있을 때 부호가 올바르게 뒤집힌다
- 각 항의 보기는 정답 절대값 기준 양수/음수 두 개다
- choice id 기준으로 채점이 올바르다
- 세션 문제 key가 중복되지 않는다
- 기존 숫자 괄호 엔진 테스트가 회귀 없이 통과한다
### 4-B. 미지수 괄호 store 추가
완료 기준: 세션 생성 및 진행 가능
- `store/variableGameStore.ts`
### 4-C. 준비 화면 추가
완료 기준: `/variable-parentheses` 접속 시 준비 화면이 표시된다
- `app/(game)/variable-parentheses/page.tsx`
- `app/(game)/variable-parentheses/VariableParenthesesClient.tsx`
### 4-D. 첫 화면 수정
완료 기준: `/`에서 `미지수 괄호 풀기`가 활성 링크로 표시된다
- `app/page.tsx` 수정
### 4-E. 선택지형 풀이 화면 추가
완료 기준: `/variable-parentheses/play?count=5`에서 문제를 풀 수 있다
- `app/(game)/variable-parentheses/play/page.tsx`
- `app/(game)/variable-parentheses/play/VariablePlayClient.tsx`
- `components/game/VariableChoiceAnswerForm.tsx`
### 4-F. 피드백 및 결과 화면 추가
완료 기준: 선택 결과에 따라 피드백과 최종 결과가 표시된다
- `components/game/VariableFeedbackPanel.tsx`
- `components/game/VariableScoreSummary.tsx`
### 4-G. 회귀 확인
완료 기준: 숫자 괄호와 미지수 괄호 흐름이 모두 동작한다
1. `/``숫자 괄호 풀기` → 준비 화면 → `/play?count=5` → 풀이 → 결과
2. `/``미지수 괄호 풀기` → 준비 화면 → `/variable-parentheses/play?count=5` → 풀이 → 결과
---
## 생성 또는 수정 파일
### 신규 생성
```text
lib/engine/variable-types.ts
lib/engine/variable-parenthesis.ts
lib/engine/variable-parenthesis.test.ts
store/variableGameStore.ts
app/(game)/variable-parentheses/page.tsx
app/(game)/variable-parentheses/VariableParenthesesClient.tsx
app/(game)/variable-parentheses/play/page.tsx
app/(game)/variable-parentheses/play/VariablePlayClient.tsx
components/game/VariableChoiceAnswerForm.tsx
components/game/VariableFeedbackPanel.tsx
components/game/VariableScoreSummary.tsx
```
### 수정
```text
app/page.tsx
```
### 변경 없음
```text
app/(game)/play/page.tsx
app/(game)/play/PlayClient.tsx
store/gameStore.ts
lib/engine/parenthesis-sign.ts
lib/engine/parenthesis-sign.test.ts
```
---
## 완료 기준
- `/`에서 `미지수 괄호 풀기`가 활성화되어 있다
- `/variable-parentheses` 준비 화면에서 문제 수 `5`, `10`을 선택할 수 있다
- `()`가 selected 상태이고 `{}`, `[]`는 disabled 상태다
- `연습 시작`으로 `/variable-parentheses/play?count=N`에 진입한다
- 미지수 괄호 문제는 괄호 안 두 항 중 하나만 `정수x` 항을 포함한다
- 풀이 화면에서 첫째 항, 둘째 항을 순서대로 보기에서 선택할 수 있다
- 각 항의 보기는 정답 절대값 기준 음수/양수 두 개다
- 두 항 선택 후 `정답 확인` 버튼으로 제출할 수 있다
- 정답·오답 피드백이 기존 색상과 기호 규칙(`○`, `×`)으로 표시된다
- 최종 결과 화면이 표시된다
- 기존 숫자 괄호 풀이 화면은 현재 동작을 유지한다
- `pnpm test` 통과
- `pnpm build` 통과
+48
View File
@@ -0,0 +1,48 @@
export const ACTIVE_DISTRIBUTION_COLOR = "#f97316";
function colorLatex(latex: string, color: string) {
return `\\textcolor{${color}}{${latex}}`;
}
interface FormatParenthesisExpressionLatexParams<T> {
multiplier: number;
terms: T[];
operators: ("+" | "-")[];
formatTermLatex: (term: T) => string;
activeTermIndex?: number;
activeColor?: string;
}
export function formatParenthesisExpressionLatex<T>({
multiplier,
terms,
operators,
formatTermLatex,
activeTermIndex,
activeColor = ACTIVE_DISTRIBUTION_COLOR,
}: FormatParenthesisExpressionLatexParams<T>) {
const hasActiveTerm = activeTermIndex !== undefined;
const maybeColor = (latex: string, active: boolean) =>
hasActiveTerm && active ? colorLatex(latex, activeColor) : latex;
const termLatex = terms
.map((term, index) => {
const displayTerm = maybeColor(
formatTermLatex(term),
index === activeTermIndex,
);
if (index === 0) return displayTerm;
return `${operators[index - 1]} ${displayTerm}`;
})
.join(" ");
if (hasActiveTerm) {
return `${colorLatex(`${multiplier}`, activeColor)}${colorLatex(
"(",
activeColor,
)}${termLatex}${colorLatex(")", activeColor)}`;
}
return `${multiplier}\\left(${termLatex}\\right)`;
}
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import {
generateProblem,
generateSession,
gradeAnswer,
} from "./parenthesis-sign";
describe("parenthesis sign problem engine", () => {
it("generates a problem within the configured ranges", () => {
for (let index = 0; index < 100; index += 1) {
const problem = generateProblem();
expect(Number.isInteger(problem.multiplier)).toBe(true);
expect(problem.multiplier).toBeGreaterThanOrEqual(-9);
expect(problem.multiplier).toBeLessThanOrEqual(-1);
expect(problem.terms).toHaveLength(2);
expect(problem.operators).toHaveLength(1);
expect(problem.terms.every((term) => term >= 1 && term <= 9)).toBe(true);
expect(new Set(problem.terms).size).toBe(problem.terms.length);
expect(["+", "-"]).toContain(problem.operators[0]);
}
});
it("distributes the multiplier correctly for both operators", () => {
for (let index = 0; index < 100; index += 1) {
const problem = generateProblem();
const signedTerms = problem.terms.map((term, termIndex) => {
if (termIndex === 0) return term;
return problem.operators[termIndex - 1] === "-" ? -term : term;
});
expect(problem.distributedTerms).toEqual(
signedTerms.map((term) => problem.multiplier * term),
);
}
});
it("grades correct and incorrect answers", () => {
const problem = generateProblem();
const correctResult = gradeAnswer(problem, {
terms: problem.distributedTerms,
});
expect(correctResult.correct).toBe(true);
expect(correctResult.userAnswer).toEqual(problem.distributedTerms);
expect(
gradeAnswer(problem, {
terms: [...problem.distributedTerms].reverse(),
}).correct,
).toBe(false);
});
it("generates a session with unique problem keys", () => {
const problems = generateSession(10);
const keys = new Set(problems.map((problem) => problem.key));
expect(problems).toHaveLength(10);
expect(keys.size).toBe(10);
});
it("generates the selected number of terms inside parentheses", () => {
const problem = generateProblem(4);
expect(problem.terms).toHaveLength(4);
expect(problem.operators).toHaveLength(3);
expect(problem.distributedTerms).toHaveLength(4);
expect(
gradeAnswer(problem, { terms: problem.distributedTerms }).correct,
).toBe(true);
});
it("rejects impossible session sizes", () => {
expect(() => generateSession(0)).toThrow();
expect(() => generateSession(1297)).toThrow();
});
});
+214
View File
@@ -0,0 +1,214 @@
import type {
GradeResult,
ParenthesisSignProblem,
UserAnswer,
} from "./types";
import { formatParenthesisExpressionLatex } from "./parenthesis-latex";
const MIN_MULTIPLIER = -9;
const MAX_MULTIPLIER = -1;
const MIN_TERM = 1;
const MAX_TERM = 9;
const MAX_TERM_COUNT = 4;
export type ParenthesisTermCount = 2 | 3 | 4;
interface ProblemSeed {
multiplier: number;
terms: number[];
operators: ("+" | "-")[];
}
function randomInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function formatLatexTerms(terms: number[]) {
return terms
.map((term, index) => {
if (index === 0) return `${term}`;
const operator = term < 0 ? "-" : "+";
return `${operator} ${Math.abs(term)}`;
})
.join(" ");
}
function createProblem({
multiplier,
terms,
operators,
}: ProblemSeed): ParenthesisSignProblem {
const signedTerms = terms.map((term, index) => {
if (index === 0) return term;
return operators[index - 1] === "-" ? -term : term;
});
const distributedTerms = signedTerms.map((term) => multiplier * term);
const key = [
multiplier,
...terms.flatMap((term, index) =>
index === 0 ? [`${term}`] : [operators[index - 1], `${term}`],
),
].join(":");
return {
id: `parenthesis-sign:${key}`,
key,
multiplier,
terms,
operators,
distributedTerms,
latexBefore: formatParenthesisExpressionLatex({
multiplier,
terms,
operators,
formatTermLatex: (term) => `${term}`,
}),
latexAfter: formatLatexTerms(distributedTerms),
};
}
function createTermCombinations(termCount: ParenthesisTermCount) {
const combinations: number[][] = [];
function addTerms(nextTerms: number[]) {
if (nextTerms.length === termCount) {
combinations.push(nextTerms);
return;
}
for (let term = MIN_TERM; term <= MAX_TERM; term += 1) {
if (nextTerms.includes(term)) continue;
addTerms([...nextTerms, term]);
}
}
addTerms([]);
return combinations;
}
function createOperatorCombinations(termCount: ParenthesisTermCount) {
const combinations: ("+" | "-")[][] = [];
const operatorCount = termCount - 1;
function addOperators(nextOperators: ("+" | "-")[]) {
if (nextOperators.length === operatorCount) {
combinations.push(nextOperators);
return;
}
addOperators([...nextOperators, "+"]);
addOperators([...nextOperators, "-"]);
}
addOperators([]);
return combinations;
}
function createAllProblemSeeds(termCount: ParenthesisTermCount) {
const seeds: ProblemSeed[] = [];
const termCombinations = createTermCombinations(termCount);
const operatorCombinations = createOperatorCombinations(termCount);
for (
let multiplier = MIN_MULTIPLIER;
multiplier <= MAX_MULTIPLIER;
multiplier += 1
) {
for (const terms of termCombinations) {
for (const operators of operatorCombinations) {
seeds.push({ multiplier, terms, operators });
}
}
}
return seeds;
}
const ALL_PROBLEM_SEEDS_BY_TERM_COUNT: Record<
ParenthesisTermCount,
ProblemSeed[]
> = {
2: createAllProblemSeeds(2),
3: createAllProblemSeeds(3),
4: createAllProblemSeeds(4),
};
function shuffle<T>(items: T[]) {
const shuffled = [...items];
for (let index = shuffled.length - 1; index > 0; index -= 1) {
const targetIndex = randomInt(0, index);
const current = shuffled[index];
shuffled[index] = shuffled[targetIndex];
shuffled[targetIndex] = current;
}
return shuffled;
}
function assertTermCount(termCount: number): asserts termCount is ParenthesisTermCount {
if (!Number.isInteger(termCount) || termCount < 2 || termCount > MAX_TERM_COUNT) {
throw new Error("termCount must be 2, 3, or 4.");
}
}
export function generateProblem(
termCount: ParenthesisTermCount = 2,
): ParenthesisSignProblem {
assertTermCount(termCount);
const terms: number[] = [];
while (terms.length < termCount) {
const term = randomInt(MIN_TERM, MAX_TERM);
if (!terms.includes(term)) {
terms.push(term);
}
}
return createProblem({
multiplier: randomInt(MIN_MULTIPLIER, MAX_MULTIPLIER),
terms,
operators: Array.from({ length: termCount - 1 }, () =>
Math.random() < 0.5 ? "+" : "-",
),
});
}
export function generateSession(
count = 10,
termCount: ParenthesisTermCount = 2,
): ParenthesisSignProblem[] {
if (!Number.isInteger(count) || count < 1) {
throw new Error("count must be a positive integer.");
}
assertTermCount(termCount);
const seeds = ALL_PROBLEM_SEEDS_BY_TERM_COUNT[termCount];
if (count > seeds.length) {
throw new Error(`count cannot exceed ${seeds.length}.`);
}
return shuffle(seeds).slice(0, count).map(createProblem);
}
export function gradeAnswer(
problem: ParenthesisSignProblem,
answer: UserAnswer,
): GradeResult {
return {
correct:
answer.terms.length === problem.distributedTerms.length &&
answer.terms.every((value, index) => value === problem.distributedTerms[index]),
userAnswer: answer.terms,
correctAnswer: problem.distributedTerms,
};
}
+20
View File
@@ -0,0 +1,20 @@
export interface ParenthesisSignProblem {
id: string;
key: string;
multiplier: number;
terms: number[];
operators: ("+" | "-")[];
distributedTerms: number[];
latexBefore: string;
latexAfter: string;
}
export interface UserAnswer {
terms: number[];
}
export interface GradeResult {
correct: boolean;
userAnswer: number[];
correctAnswer: number[];
}
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import {
formatVariableExpressionLatex,
generateVariableParenthesisProblem,
generateVariableParenthesisSession,
gradeVariableParenthesisAnswer,
} from "./variable-parenthesis";
import type { VariableParenthesisProblem } from "./variable-types";
function findCorrectChoiceIds(problem: VariableParenthesisProblem) {
return problem.correctTerms.map((term, index) => {
const choices = problem.choices[index];
const choice = choices.find(
(item) =>
item.coefficient === term.coefficient && item.variable === term.variable,
);
if (!choice) throw new Error("correct choice not found.");
return choice.id;
});
}
describe("variable parenthesis engine", () => {
it("generates a problem with exactly one variable term inside parentheses", () => {
const problem = generateVariableParenthesisProblem();
expect(problem.terms.filter((term) => term.kind === "variable")).toHaveLength(1);
expect(problem.terms.filter((term) => term.kind === "constant")).toHaveLength(1);
expect(problem.multiplier).toBeLessThan(0);
expect(Number.isInteger(problem.multiplier)).toBe(true);
expect(
problem.terms.every((term) => term.coefficient >= 1 && term.coefficient <= 9),
).toBe(true);
});
it("distributes signs correctly for addition and subtraction", () => {
const problems = generateVariableParenthesisSession(50);
for (const problem of problems) {
expect(problem.correctTerms.map((term) => term.coefficient)).toEqual(
problem.terms.map((term, index) => {
const sign = index === 0 || problem.operators[index - 1] === "+" ? 1 : -1;
return problem.multiplier * sign * term.coefficient;
}),
);
}
});
it("creates positive and negative choices for each term", () => {
const problem = generateVariableParenthesisProblem();
problem.choices.forEach((choices, index) => {
const correctTerm = problem.correctTerms[index as 0 | 1];
const coefficients = choices.map((choice) => choice.coefficient).sort();
expect(choices).toHaveLength(2);
expect(coefficients).toEqual([
-Math.abs(correctTerm.coefficient),
Math.abs(correctTerm.coefficient),
]);
expect(choices.every((choice) => choice.variable === correctTerm.variable))
.toBe(true);
});
});
it("grades selected choice ids", () => {
const problem = generateVariableParenthesisProblem();
const correctChoiceIds = findCorrectChoiceIds(problem);
const result = gradeVariableParenthesisAnswer(problem, {
selectedChoiceIds: correctChoiceIds,
});
expect(result.correct).toBe(true);
expect(result.correctAnswer).toEqual(problem.correctTerms);
const wrongFirstChoice = problem.choices[0].find(
(choice) => choice.id !== correctChoiceIds[0],
);
expect(wrongFirstChoice).toBeDefined();
expect(
gradeVariableParenthesisAnswer(problem, {
selectedChoiceIds: [wrongFirstChoice!.id, correctChoiceIds[1]],
}).correct,
).toBe(false);
});
it("generates sessions without duplicate keys", () => {
const problems = generateVariableParenthesisSession(10);
const keys = new Set(problems.map((problem) => problem.key));
expect(problems).toHaveLength(10);
expect(keys.size).toBe(10);
});
it("generates the selected number of variable choices", () => {
const problem = generateVariableParenthesisProblem(4);
expect(problem.terms).toHaveLength(4);
expect(problem.operators).toHaveLength(3);
expect(problem.correctTerms).toHaveLength(4);
expect(problem.choices).toHaveLength(4);
expect(problem.terms.filter((term) => term.kind === "variable")).toHaveLength(1);
expect(
gradeVariableParenthesisAnswer(problem, {
selectedChoiceIds: findCorrectChoiceIds(problem),
}).correct,
).toBe(true);
});
it("formats expressions with variable terms", () => {
expect(
formatVariableExpressionLatex([
{ kind: "variable", coefficient: -6, variable: "x", latex: "-6x" },
{ kind: "constant", coefficient: 12, latex: "12" },
]),
).toBe("-6x + 12");
});
});
+358
View File
@@ -0,0 +1,358 @@
import type {
DistributedVariableTerm,
VariableParenthesisProblem,
VariableParenthesisTerm,
VariableTermChoice,
VariableUserAnswer,
VariableGradeResult,
} from "./variable-types";
import { formatParenthesisExpressionLatex } from "./parenthesis-latex";
const MIN_MULTIPLIER = -9;
const MAX_MULTIPLIER = -1;
const MIN_COEFFICIENT = 1;
const MAX_COEFFICIENT = 9;
const MAX_TERM_COUNT = 4;
export type VariableParenthesisTermCount = 2 | 3 | 4;
interface VariableProblemSeed {
multiplier: number;
terms: VariableParenthesisTerm[];
operators: ("+" | "-")[];
}
function randomInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function shuffle<T>(items: T[]) {
const shuffled = [...items];
for (let index = shuffled.length - 1; index > 0; index -= 1) {
const targetIndex = randomInt(0, index);
const current = shuffled[index];
shuffled[index] = shuffled[targetIndex];
shuffled[targetIndex] = current;
}
return shuffled;
}
function createConstantTerm(coefficient: number): VariableParenthesisTerm {
return {
kind: "constant",
coefficient,
};
}
function createVariableTerm(coefficient: number): VariableParenthesisTerm {
return {
kind: "variable",
coefficient,
variable: "x",
};
}
function createUniqueConstantTerms(
baseCoefficient: number,
count: number,
): VariableParenthesisTerm[] {
const used = new Set<number>();
const terms: VariableParenthesisTerm[] = [];
let candidate = baseCoefficient;
while (terms.length < count) {
const coef = ((candidate - 1) % MAX_COEFFICIENT) + 1;
if (!used.has(coef)) {
used.add(coef);
terms.push(createConstantTerm(coef));
}
candidate += 1;
}
return terms;
}
function termKey(term: VariableParenthesisTerm) {
return `${term.kind}:${term.coefficient}`;
}
export function formatVariableTermLatex(
term: Pick<VariableParenthesisTerm, "kind" | "coefficient" | "variable">,
) {
if (term.kind === "constant") {
return `${term.coefficient}`;
}
if (term.coefficient === 1) {
return "x";
}
if (term.coefficient === -1) {
return "-x";
}
return `${term.coefficient}x`;
}
export function formatVariableExpressionLatex(
terms: DistributedVariableTerm[],
) {
return terms
.map((term, index) => {
if (index === 0) return term.latex;
const operator = term.coefficient < 0 ? "-" : "+";
const positiveTerm = {
...term,
coefficient: Math.abs(term.coefficient),
};
return `${operator} ${formatVariableTermLatex(positiveTerm)}`;
})
.join(" ");
}
function distributeTerm(
multiplier: number,
term: VariableParenthesisTerm,
sign: 1 | -1,
): DistributedVariableTerm {
const coefficient = multiplier * sign * term.coefficient;
return {
kind: term.kind,
coefficient,
variable: term.variable,
latex: formatVariableTermLatex({ ...term, coefficient }),
};
}
function createChoice(
key: string,
termIndex: number,
term: DistributedVariableTerm,
coefficient: number,
): VariableTermChoice {
const signKey = coefficient < 0 ? "negative" : "positive";
return {
id: `${key}:term-${termIndex}:${signKey}`,
coefficient,
variable: term.variable,
latex: formatVariableTermLatex({ ...term, coefficient }),
};
}
function createChoices(
key: string,
termIndex: number,
term: DistributedVariableTerm,
) {
const absoluteCoefficient = Math.abs(term.coefficient);
const choices: VariableTermChoice[] = [
createChoice(key, termIndex, term, -absoluteCoefficient),
createChoice(key, termIndex, term, absoluteCoefficient),
];
return shuffle(choices);
}
function createProblem(seed: VariableProblemSeed): VariableParenthesisProblem {
const { multiplier, terms, operators } = seed;
const key = [
multiplier,
...terms.flatMap((term, index) =>
index === 0 ? [termKey(term)] : [operators[index - 1], termKey(term)],
),
].join(":");
const correctTerms = terms.map((term, index) =>
distributeTerm(
multiplier,
term,
index === 0 || operators[index - 1] === "+" ? 1 : -1,
),
);
const choices = correctTerms.map((term, index) =>
createChoices(key, index, term),
);
return {
id: `variable-parenthesis:${key}`,
key,
multiplier,
terms,
operators,
correctTerms,
choices,
latexBefore: formatParenthesisExpressionLatex({
multiplier,
terms,
operators,
formatTermLatex: formatVariableTermLatex,
}),
latexAfter: formatVariableExpressionLatex(correctTerms),
};
}
function createOperatorCombinations(termCount: VariableParenthesisTermCount) {
const combinations: ("+" | "-")[][] = [];
function addOperators(nextOperators: ("+" | "-")[]) {
if (nextOperators.length === termCount - 1) {
combinations.push(nextOperators);
return;
}
addOperators([...nextOperators, "+"]);
addOperators([...nextOperators, "-"]);
}
addOperators([]);
return combinations;
}
function createAllProblemSeeds(termCount: VariableParenthesisTermCount) {
const seeds: VariableProblemSeed[] = [];
const operatorCombinations = createOperatorCombinations(termCount);
for (
let multiplier = MIN_MULTIPLIER;
multiplier <= MAX_MULTIPLIER;
multiplier += 1
) {
for (
let constant = MIN_COEFFICIENT;
constant <= MAX_COEFFICIENT;
constant += 1
) {
for (
let variableCoefficient = MIN_COEFFICIENT;
variableCoefficient <= MAX_COEFFICIENT;
variableCoefficient += 1
) {
for (let variableIndex = 0; variableIndex < termCount; variableIndex += 1) {
const constantTerms = createUniqueConstantTerms(constant, termCount - 1);
let constantTermIndex = 0;
const terms = Array.from({ length: termCount }, (_, index) =>
index === variableIndex
? createVariableTerm(variableCoefficient)
: constantTerms[constantTermIndex++],
);
for (const operators of operatorCombinations) {
seeds.push({
multiplier,
terms,
operators,
});
}
}
}
}
}
return seeds;
}
const ALL_PROBLEM_SEEDS_BY_TERM_COUNT: Record<
VariableParenthesisTermCount,
VariableProblemSeed[]
> = {
2: createAllProblemSeeds(2),
3: createAllProblemSeeds(3),
4: createAllProblemSeeds(4),
};
function assertTermCount(
termCount: number,
): asserts termCount is VariableParenthesisTermCount {
if (!Number.isInteger(termCount) || termCount < 2 || termCount > MAX_TERM_COUNT) {
throw new Error("termCount must be 2, 3, or 4.");
}
}
export function generateVariableParenthesisProblem(
termCount: VariableParenthesisTermCount = 2,
): VariableParenthesisProblem {
assertTermCount(termCount);
const constant = randomInt(MIN_COEFFICIENT, MAX_COEFFICIENT);
const variableCoefficient = randomInt(MIN_COEFFICIENT, MAX_COEFFICIENT);
const variableIndex = randomInt(0, termCount - 1);
const constantTerms = createUniqueConstantTerms(constant, termCount - 1);
let constantTermIndex = 0;
return createProblem({
multiplier: randomInt(MIN_MULTIPLIER, MAX_MULTIPLIER),
terms: Array.from({ length: termCount }, (_, index) =>
index === variableIndex
? createVariableTerm(variableCoefficient)
: constantTerms[constantTermIndex++],
),
operators: Array.from({ length: termCount - 1 }, () =>
Math.random() < 0.5 ? "+" : "-",
),
});
}
export function generateVariableParenthesisSession(
count = 10,
termCount: VariableParenthesisTermCount = 2,
): VariableParenthesisProblem[] {
if (!Number.isInteger(count) || count < 1) {
throw new Error("count must be a positive integer.");
}
assertTermCount(termCount);
const seeds = ALL_PROBLEM_SEEDS_BY_TERM_COUNT[termCount];
if (count > seeds.length) {
throw new Error(`count cannot exceed ${seeds.length}.`);
}
return shuffle(seeds).slice(0, count).map(createProblem);
}
function findSelectedChoice(
problem: VariableParenthesisProblem,
termIndex: number,
choiceId: string | null,
): DistributedVariableTerm | null {
if (!choiceId) return null;
const choice = problem.choices[termIndex].find((item) => item.id === choiceId);
if (!choice) return null;
return {
kind: problem.correctTerms[termIndex].kind,
coefficient: choice.coefficient,
variable: choice.variable,
latex: choice.latex,
};
}
export function gradeVariableParenthesisAnswer(
problem: VariableParenthesisProblem,
answer: VariableUserAnswer,
): VariableGradeResult {
const userAnswer = problem.correctTerms.map((_, index) =>
findSelectedChoice(problem, index, answer.selectedChoiceIds[index] ?? null),
);
const correct =
userAnswer.length === problem.correctTerms.length &&
userAnswer.every(
(term, index) => term?.coefficient === problem.correctTerms[index].coefficient,
);
return {
correct,
userAnswer,
correctAnswer: problem.correctTerms,
};
}
+43
View File
@@ -0,0 +1,43 @@
export type VariableTermKind = "constant" | "variable";
export interface VariableParenthesisTerm {
kind: VariableTermKind;
coefficient: number;
variable?: "x";
}
export interface DistributedVariableTerm {
kind: VariableTermKind;
coefficient: number;
variable?: "x";
latex: string;
}
export interface VariableTermChoice {
id: string;
coefficient: number;
variable?: "x";
latex: string;
}
export interface VariableParenthesisProblem {
id: string;
key: string;
multiplier: number;
terms: VariableParenthesisTerm[];
operators: ("+" | "-")[];
correctTerms: DistributedVariableTerm[];
choices: VariableTermChoice[][];
latexBefore: string;
latexAfter: string;
}
export interface VariableUserAnswer {
selectedChoiceIds: (string | null)[];
}
export interface VariableGradeResult {
correct: boolean;
userAnswer: (DistributedVariableTerm | null)[];
correctAnswer: DistributedVariableTerm[];
}
+2
View File
@@ -0,0 +1,2 @@
export const DESKTOP_INPUT_DEVICE_QUERY =
"(any-hover: hover) and (any-pointer: fine)";
+3 -1
View File
@@ -1,4 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
const nextConfig = {
output: "standalone",
};
export default nextConfig;
+11 -4
View File
@@ -2,25 +2,32 @@
"name": "math-quiz",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@11.3.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"postbuild": "mkdir -p .next/standalone/.next && cp -R .next/static .next/standalone/.next/static",
"start": "next start",
"test": "vitest run",
"lint": "next lint"
},
"dependencies": {
"katex": "^0.17.0",
"next": "14.2.35",
"react": "^18",
"react-dom": "^18",
"next": "14.2.35"
"zustand": "^5.0.13"
},
"devDependencies": {
"typescript": "^5",
"@types/katex": "^0.16.8",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.35",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"eslint": "^8",
"eslint-config-next": "14.2.35"
"typescript": "^5",
"vitest": "^4.1.7"
}
}
+673
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
katex:
specifier: ^0.17.0
version: 0.17.0
next:
specifier: 14.2.35
version: 14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -17,7 +20,13 @@ importers:
react-dom:
specifier: ^18
version: 18.3.1(react@18.3.1)
zustand:
specifier: ^5.0.13
version: 5.0.13(@types/react@18.3.29)(react@18.3.1)
devDependencies:
'@types/katex':
specifier: ^0.16.8
version: 0.16.8
'@types/node':
specifier: ^20
version: 20.19.41
@@ -42,6 +51,9 @@ importers:
typescript:
specifier: ^5
version: 5.9.3
vitest:
specifier: ^4.1.7
version: 4.1.7(@types/node@20.19.41)(vite@8.0.14(@types/node@20.19.41)(jiti@1.21.7))
packages:
@@ -192,16 +204,120 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@oxc-project/types@0.132.0':
resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@rolldown/binding-android-arm64@1.0.2':
resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/binding-darwin-arm64@1.0.2':
resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/binding-darwin-x64@1.0.2':
resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/binding-freebsd-x64@1.0.2':
resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/binding-linux-arm-gnueabihf@1.0.2':
resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/binding-linux-arm64-gnu@1.0.2':
resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.2':
resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.2':
resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.2':
resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.2':
resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.2':
resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.2':
resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/binding-wasm32-wasi@1.0.2':
resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [wasm32]
'@rolldown/binding-win32-arm64-msvc@1.0.2':
resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/binding-win32-x64-msvc@1.0.2':
resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
'@rushstack/eslint-patch@1.16.1':
resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
@@ -211,9 +327,21 @@ packages:
'@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/katex@0.16.8':
resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==}
'@types/node@20.19.41':
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
@@ -410,6 +538,35 @@ packages:
cpu: [x64]
os: [win32]
'@vitest/expect@4.1.7':
resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==}
'@vitest/mocker@4.1.7':
resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@4.1.7':
resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==}
'@vitest/runner@4.1.7':
resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==}
'@vitest/snapshot@4.1.7':
resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==}
'@vitest/spy@4.1.7':
resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==}
'@vitest/utils@4.1.7':
resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -488,6 +645,10 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-types-flow@0.0.8:
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
@@ -559,6 +720,10 @@ packages:
caniuse-lite@1.0.30001793:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -581,9 +746,16 @@ packages:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
commander@8.3.0:
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
engines: {node: '>= 12'}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -639,6 +811,10 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
@@ -682,6 +858,9 @@ packages:
resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==}
engines: {node: '>= 0.4'}
es-module-lexer@2.1.0:
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
@@ -810,10 +989,17 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1150,6 +1336,10 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
katex@0.17.0:
resolution: {integrity: sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==}
hasBin: true
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -1164,6 +1354,80 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
lightningcss-darwin-arm64@1.32.0:
resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.32.0:
resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.32.0:
resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-linux-arm-gnueabihf@1.32.0:
resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm64-gnu@1.32.0:
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-x64-msvc@1.32.0:
resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.32.0:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
@@ -1185,6 +1449,9 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -1296,6 +1563,9 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@@ -1338,6 +1608,9 @@ packages:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1479,6 +1752,11 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
rolldown@1.0.2:
resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -1542,6 +1820,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
@@ -1553,6 +1834,12 @@ packages:
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
@@ -1649,10 +1936,21 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@1.1.2:
resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==}
engines: {node: '>=18'}
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@@ -1717,6 +2015,90 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
vite@8.0.14:
resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
'@vitejs/devtools': ^0.1.18
esbuild: ^0.27.0 || ^0.28.0
jiti: '>=1.21.0'
less: ^4.0.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
'@vitejs/devtools':
optional: true
esbuild:
optional: true
jiti:
optional: true
less:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
vitest@4.1.7:
resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.7
'@vitest/browser-preview': 4.1.7
'@vitest/browser-webdriverio': 4.1.7
'@vitest/coverage-istanbul': 4.1.7
'@vitest/coverage-v8': 4.1.7
'@vitest/ui': 4.1.7
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -1738,6 +2120,11 @@ packages:
engines: {node: '>= 8'}
hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
@@ -1757,6 +2144,24 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
zustand@5.0.13:
resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots:
'@alloc/quick-lru@5.2.0': {}
@@ -1889,13 +2294,68 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@oxc-project/types@0.132.0': {}
'@pkgjs/parseargs@0.11.0':
optional: true
'@rolldown/binding-android-arm64@1.0.2':
optional: true
'@rolldown/binding-darwin-arm64@1.0.2':
optional: true
'@rolldown/binding-darwin-x64@1.0.2':
optional: true
'@rolldown/binding-freebsd-x64@1.0.2':
optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.0.2':
optional: true
'@rolldown/binding-linux-arm64-gnu@1.0.2':
optional: true
'@rolldown/binding-linux-arm64-musl@1.0.2':
optional: true
'@rolldown/binding-linux-ppc64-gnu@1.0.2':
optional: true
'@rolldown/binding-linux-s390x-gnu@1.0.2':
optional: true
'@rolldown/binding-linux-x64-gnu@1.0.2':
optional: true
'@rolldown/binding-linux-x64-musl@1.0.2':
optional: true
'@rolldown/binding-openharmony-arm64@1.0.2':
optional: true
'@rolldown/binding-wasm32-wasi@1.0.2':
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
optional: true
'@rolldown/binding-win32-arm64-msvc@1.0.2':
optional: true
'@rolldown/binding-win32-x64-msvc@1.0.2':
optional: true
'@rolldown/pluginutils@1.0.1': {}
'@rtsao/scc@1.1.0': {}
'@rushstack/eslint-patch@1.16.1': {}
'@standard-schema/spec@1.1.0': {}
'@swc/counter@0.1.3': {}
'@swc/helpers@0.5.5':
@@ -1908,8 +2368,19 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.9': {}
'@types/json5@0.0.29': {}
'@types/katex@0.16.8': {}
'@types/node@20.19.41':
dependencies:
undici-types: 6.21.0
@@ -2088,6 +2559,47 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true
'@vitest/expect@4.1.7':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.7
'@vitest/utils': 4.1.7
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.7(vite@8.0.14(@types/node@20.19.41)(jiti@1.21.7))':
dependencies:
'@vitest/spy': 4.1.7
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.14(@types/node@20.19.41)(jiti@1.21.7)
'@vitest/pretty-format@4.1.7':
dependencies:
tinyrainbow: 3.1.0
'@vitest/runner@4.1.7':
dependencies:
'@vitest/utils': 4.1.7
pathe: 2.0.3
'@vitest/snapshot@4.1.7':
dependencies:
'@vitest/pretty-format': 4.1.7
'@vitest/utils': 4.1.7
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@4.1.7': {}
'@vitest/utils@4.1.7':
dependencies:
'@vitest/pretty-format': 4.1.7
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
acorn: 8.16.0
@@ -2191,6 +2703,8 @@ snapshots:
get-intrinsic: 1.3.0
is-array-buffer: 3.0.5
assertion-error@2.0.1: {}
ast-types-flow@0.0.8: {}
async-function@1.0.0: {}
@@ -2253,6 +2767,8 @@ snapshots:
caniuse-lite@1.0.30001793: {}
chai@6.2.2: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -2280,8 +2796,12 @@ snapshots:
commander@4.1.1: {}
commander@8.3.0: {}
concat-map@0.0.1: {}
convert-source-map@2.0.0: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -2334,6 +2854,8 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
detect-libc@2.1.2: {}
didyoumean@1.2.2: {}
dlv@1.1.3: {}
@@ -2438,6 +2960,8 @@ snapshots:
iterator.prototype: 1.1.5
math-intrinsics: 1.1.0
es-module-lexer@2.1.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
@@ -2657,8 +3181,14 @@ snapshots:
estraverse@5.3.0: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
esutils@2.0.3: {}
expect-type@1.3.0: {}
fast-deep-equal@3.1.3: {}
fast-glob@3.3.3:
@@ -3009,6 +3539,10 @@ snapshots:
object.assign: 4.1.7
object.values: 1.2.1
katex@0.17.0:
dependencies:
commander: 8.3.0
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -3024,6 +3558,55 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
lightningcss-android-arm64@1.32.0:
optional: true
lightningcss-darwin-arm64@1.32.0:
optional: true
lightningcss-darwin-x64@1.32.0:
optional: true
lightningcss-freebsd-x64@1.32.0:
optional: true
lightningcss-linux-arm-gnueabihf@1.32.0:
optional: true
lightningcss-linux-arm64-gnu@1.32.0:
optional: true
lightningcss-linux-arm64-musl@1.32.0:
optional: true
lightningcss-linux-x64-gnu@1.32.0:
optional: true
lightningcss-linux-x64-musl@1.32.0:
optional: true
lightningcss-win32-arm64-msvc@1.32.0:
optional: true
lightningcss-win32-x64-msvc@1.32.0:
optional: true
lightningcss@1.32.0:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
lightningcss-android-arm64: 1.32.0
lightningcss-darwin-arm64: 1.32.0
lightningcss-darwin-x64: 1.32.0
lightningcss-freebsd-x64: 1.32.0
lightningcss-linux-arm-gnueabihf: 1.32.0
lightningcss-linux-arm64-gnu: 1.32.0
lightningcss-linux-arm64-musl: 1.32.0
lightningcss-linux-x64-gnu: 1.32.0
lightningcss-linux-x64-musl: 1.32.0
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
@@ -3040,6 +3623,10 @@ snapshots:
lru-cache@10.4.3: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
math-intrinsics@1.1.0: {}
merge2@1.4.1: {}
@@ -3157,6 +3744,8 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
obug@2.1.1: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
@@ -3201,6 +3790,8 @@ snapshots:
lru-cache: 10.4.3
minipass: 7.1.3
pathe@2.0.3: {}
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@@ -3334,6 +3925,27 @@ snapshots:
dependencies:
glob: 7.2.3
rolldown@1.0.2:
dependencies:
'@oxc-project/types': 0.132.0
'@rolldown/pluginutils': 1.0.1
optionalDependencies:
'@rolldown/binding-android-arm64': 1.0.2
'@rolldown/binding-darwin-arm64': 1.0.2
'@rolldown/binding-darwin-x64': 1.0.2
'@rolldown/binding-freebsd-x64': 1.0.2
'@rolldown/binding-linux-arm-gnueabihf': 1.0.2
'@rolldown/binding-linux-arm64-gnu': 1.0.2
'@rolldown/binding-linux-arm64-musl': 1.0.2
'@rolldown/binding-linux-ppc64-gnu': 1.0.2
'@rolldown/binding-linux-s390x-gnu': 1.0.2
'@rolldown/binding-linux-x64-gnu': 1.0.2
'@rolldown/binding-linux-x64-musl': 1.0.2
'@rolldown/binding-openharmony-arm64': 1.0.2
'@rolldown/binding-wasm32-wasi': 1.0.2
'@rolldown/binding-win32-arm64-msvc': 1.0.2
'@rolldown/binding-win32-x64-msvc': 1.0.2
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -3421,12 +4033,18 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
signal-exit@4.1.0: {}
source-map-js@1.2.1: {}
stable-hash@0.0.5: {}
stackback@0.0.2: {}
std-env@4.1.0: {}
stop-iteration-iterator@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -3567,11 +4185,17 @@ snapshots:
dependencies:
any-promise: 1.3.0
tinybench@2.9.0: {}
tinyexec@1.1.2: {}
tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinyrainbow@3.1.0: {}
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
@@ -3674,6 +4298,45 @@ snapshots:
util-deprecate@1.0.2: {}
vite@8.0.14(@types/node@20.19.41)(jiti@1.21.7):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.15
rolldown: 1.0.2
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 20.19.41
fsevents: 2.3.3
jiti: 1.21.7
vitest@4.1.7(@types/node@20.19.41)(vite@8.0.14(@types/node@20.19.41)(jiti@1.21.7)):
dependencies:
'@vitest/expect': 4.1.7
'@vitest/mocker': 4.1.7(vite@8.0.14(@types/node@20.19.41)(jiti@1.21.7))
'@vitest/pretty-format': 4.1.7
'@vitest/runner': 4.1.7
'@vitest/snapshot': 4.1.7
'@vitest/spy': 4.1.7
'@vitest/utils': 4.1.7
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.1.2
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 8.0.14(@types/node@20.19.41)(jiti@1.21.7)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.19.41
transitivePeerDependencies:
- msw
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
@@ -3719,6 +4382,11 @@ snapshots:
dependencies:
isexe: 2.0.0
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
word-wrap@1.2.5: {}
wrap-ansi@7.0.0:
@@ -3736,3 +4404,8 @@ snapshots:
wrappy@1.0.2: {}
yocto-queue@0.1.0: {}
zustand@5.0.13(@types/react@18.3.29)(react@18.3.1):
optionalDependencies:
'@types/react': 18.3.29
react: 18.3.1
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
unrs-resolver: true
unrs-resolver@1.12.2: true
strictDepBuilds: false
+108
View File
@@ -0,0 +1,108 @@
import { create } from "zustand";
import {
generateSession,
gradeAnswer,
} from "@/lib/engine/parenthesis-sign";
import type { ParenthesisTermCount } from "@/lib/engine/parenthesis-sign";
import type {
GradeResult,
ParenthesisSignProblem,
UserAnswer,
} from "@/lib/engine/types";
export interface GameSession {
problems: ParenthesisSignProblem[];
currentIndex: number;
results: (GradeResult | null)[];
termCount: ParenthesisTermCount;
}
export type ProblemCount = 5 | 10;
export type MaxTermCount = ParenthesisTermCount;
interface GameState {
session: GameSession | null;
selectedProblemCount: ProblemCount;
selectedMaxTermCount: MaxTermCount;
setProblemCount: (count: ProblemCount) => void;
setMaxTermCount: (count: MaxTermCount) => void;
initSession: (count?: ProblemCount, maxTermCount?: MaxTermCount) => void;
submitAnswer: (answer: UserAnswer) => void;
nextProblem: () => void;
resetSession: () => void;
}
const DEFAULT_SESSION_COUNT: ProblemCount = 10;
const DEFAULT_MAX_TERM_COUNT: MaxTermCount = 2;
function createSession(
count: ProblemCount = DEFAULT_SESSION_COUNT,
maxTermCount: MaxTermCount = DEFAULT_MAX_TERM_COUNT,
): GameSession {
const problems = generateSession(count, maxTermCount);
return {
problems,
currentIndex: 0,
results: problems.map(() => null),
termCount: maxTermCount,
};
}
export const useGameStore = create<GameState>((set, get) => ({
session: null,
selectedProblemCount: DEFAULT_SESSION_COUNT,
selectedMaxTermCount: DEFAULT_MAX_TERM_COUNT,
setProblemCount: (count) => {
set({ selectedProblemCount: count });
},
setMaxTermCount: (count) => {
set({ selectedMaxTermCount: count });
},
initSession: (count, maxTermCount) => {
const selectedProblemCount = count ?? get().selectedProblemCount;
const selectedMaxTermCount = maxTermCount ?? get().selectedMaxTermCount;
set({ session: createSession(selectedProblemCount, selectedMaxTermCount) });
},
submitAnswer: (answer) => {
const { session } = get();
if (!session || session.currentIndex >= session.problems.length) return;
if (session.results[session.currentIndex] !== null) return;
const problem = session.problems[session.currentIndex];
const result = gradeAnswer(problem, answer);
const results = [...session.results];
results[session.currentIndex] = result;
set({
session: {
...session,
results,
},
});
},
nextProblem: () => {
const { session } = get();
if (!session || session.currentIndex >= session.problems.length) return;
if (session.results[session.currentIndex] === null) return;
set({
session: {
...session,
currentIndex: session.currentIndex + 1,
},
});
},
resetSession: () => {
set({
session: createSession(
get().selectedProblemCount,
get().selectedMaxTermCount,
),
});
},
}));
+111
View File
@@ -0,0 +1,111 @@
import { create } from "zustand";
import {
generateVariableParenthesisSession,
gradeVariableParenthesisAnswer,
} from "@/lib/engine/variable-parenthesis";
import type { VariableParenthesisTermCount } from "@/lib/engine/variable-parenthesis";
import type {
VariableGradeResult,
VariableParenthesisProblem,
VariableUserAnswer,
} from "@/lib/engine/variable-types";
export interface VariableGameSession {
problems: VariableParenthesisProblem[];
currentIndex: number;
results: (VariableGradeResult | null)[];
termCount: VariableParenthesisTermCount;
}
export type VariableProblemCount = 5 | 10;
export type VariableMaxTermCount = VariableParenthesisTermCount;
interface VariableGameState {
session: VariableGameSession | null;
selectedProblemCount: VariableProblemCount;
selectedMaxTermCount: VariableMaxTermCount;
setProblemCount: (count: VariableProblemCount) => void;
setMaxTermCount: (count: VariableMaxTermCount) => void;
initSession: (
count?: VariableProblemCount,
maxTermCount?: VariableMaxTermCount,
) => void;
submitAnswer: (answer: VariableUserAnswer) => void;
nextProblem: () => void;
resetSession: () => void;
}
const DEFAULT_SESSION_COUNT: VariableProblemCount = 10;
const DEFAULT_MAX_TERM_COUNT: VariableMaxTermCount = 2;
function createSession(
count: VariableProblemCount = DEFAULT_SESSION_COUNT,
maxTermCount: VariableMaxTermCount = DEFAULT_MAX_TERM_COUNT,
): VariableGameSession {
const problems = generateVariableParenthesisSession(count, maxTermCount);
return {
problems,
currentIndex: 0,
results: problems.map(() => null),
termCount: maxTermCount,
};
}
export const useVariableGameStore = create<VariableGameState>((set, get) => ({
session: null,
selectedProblemCount: DEFAULT_SESSION_COUNT,
selectedMaxTermCount: DEFAULT_MAX_TERM_COUNT,
setProblemCount: (count) => {
set({ selectedProblemCount: count });
},
setMaxTermCount: (count) => {
set({ selectedMaxTermCount: count });
},
initSession: (count, maxTermCount) => {
const selectedProblemCount = count ?? get().selectedProblemCount;
const selectedMaxTermCount = maxTermCount ?? get().selectedMaxTermCount;
set({ session: createSession(selectedProblemCount, selectedMaxTermCount) });
},
submitAnswer: (answer) => {
const { session } = get();
if (!session || session.currentIndex >= session.problems.length) return;
if (session.results[session.currentIndex] !== null) return;
const problem = session.problems[session.currentIndex];
const result = gradeVariableParenthesisAnswer(problem, answer);
const results = [...session.results];
results[session.currentIndex] = result;
set({
session: {
...session,
results,
},
});
},
nextProblem: () => {
const { session } = get();
if (!session || session.currentIndex >= session.problems.length) return;
if (session.results[session.currentIndex] === null) return;
set({
session: {
...session,
currentIndex: session.currentIndex + 1,
},
});
},
resetSession: () => {
set({
session: createSession(
get().selectedProblemCount,
get().selectedMaxTermCount,
),
});
},
}));
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});