Compare commits
No commits in common. "main" and "testbranch" have entirely different histories.
main
...
testbranch
196 changed files with 10897 additions and 8340 deletions
|
|
@ -1,20 +0,0 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
.forgejo
|
||||
.github
|
||||
.env*
|
||||
!.env.example
|
||||
dev.db*
|
||||
study.db*
|
||||
data
|
||||
coverage
|
||||
.test-databases
|
||||
.npm-cache
|
||||
.prisma-cache
|
||||
audit-results
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
out.css
|
||||
temp.css
|
||||
test.css
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
name: Verify and publish container
|
||||
name: Automated Container Build
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -11,60 +11,15 @@ jobs:
|
|||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# The changed-file lint step compares the pushed commit with its parent.
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Log into Local Registry
|
||||
run: |
|
||||
echo "${{ secrets.FORGEJO_PAT }}" | docker login git.elijahkuntz.com -u "${{ gitea.actor }}" --password-stdin
|
||||
|
||||
- name: Install locked dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Validate Prisma schema and migration drift
|
||||
- name: Build and Push Image
|
||||
run: |
|
||||
npx prisma validate
|
||||
DATABASE_URL=file:./ci-migration.test.db npx prisma migrate deploy
|
||||
npx prisma migrate diff --from-migrations prisma/migrations --to-schema prisma/schema.prisma --exit-code
|
||||
|
||||
- name: Build application
|
||||
run: npm run build
|
||||
|
||||
- name: Lint changed source files
|
||||
run: |
|
||||
if BASE=$(git rev-parse HEAD^ 2>/dev/null); then
|
||||
git diff --name-only --diff-filter=ACMR -z "$BASE" HEAD -- '*.ts' '*.tsx' '*.js' '*.mjs' |
|
||||
xargs -0 -r npx eslint
|
||||
else
|
||||
git ls-files -z -- '*.ts' '*.tsx' '*.js' '*.mjs' |
|
||||
xargs -0 -r npx eslint
|
||||
fi
|
||||
|
||||
- name: Build and smoke production image
|
||||
run: |
|
||||
IMAGE_PATH=$(echo "git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]')
|
||||
IMAGE_SHA="$IMAGE_PATH:${{ github.sha }}"
|
||||
CI_SECRET=$(openssl rand -hex 32)
|
||||
SMOKE_CONTAINER="study-smoke-$(openssl rand -hex 8)"
|
||||
cleanup_smoke() {
|
||||
docker rm -f "$SMOKE_CONTAINER" > /dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup_smoke EXIT
|
||||
docker build -t "$IMAGE_SHA" .
|
||||
docker run -d --name "$SMOKE_CONTAINER" -e SESSION_SECRET="$CI_SECRET" -e ALLOW_INITIAL_SETUP=true "$IMAGE_SHA"
|
||||
for attempt in $(seq 1 30); do
|
||||
if [ "$(docker inspect --format='{{.State.Health.Status}}' "$SMOKE_CONTAINER")" = "healthy" ]; then break; fi
|
||||
sleep 2
|
||||
done
|
||||
test "$(docker inspect --format='{{.State.Health.Status}}' "$SMOKE_CONTAINER")" = "healthy"
|
||||
docker exec "$SMOKE_CONTAINER" node -e "fetch('http://127.0.0.1:3726/login').then(r => { if (!r.ok) process.exit(1) }).catch(() => process.exit(1))"
|
||||
docker exec "$SMOKE_CONTAINER" node -e "fetch('http://127.0.0.1:3726/api/auth/setup-status').then(async r => { const body = await r.json(); if (!r.ok || body.setupRequired !== true) process.exit(1) }).catch(() => process.exit(1))"
|
||||
cleanup_smoke
|
||||
trap - EXIT
|
||||
docker tag "$IMAGE_SHA" "$IMAGE_PATH:latest"
|
||||
docker push "$IMAGE_SHA"
|
||||
docker push "$IMAGE_PATH:latest"
|
||||
# Force the entire image path string to lowercase dynamically
|
||||
IMAGE_PATH=$(echo "git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}:latest" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
docker build -t "$IMAGE_PATH" .
|
||||
docker push "$IMAGE_PATH"
|
||||
2
.gitattributes
vendored
2
.gitattributes
vendored
|
|
@ -1,2 +0,0 @@
|
|||
# Prisma 7 generated doc comments contain trailing spaces; do not hand-edit generated output.
|
||||
src/generated/prisma/** -whitespace
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -12,18 +12,12 @@
|
|||
|
||||
# testing
|
||||
/coverage
|
||||
/.test-databases/
|
||||
|
||||
# local SQLite study data
|
||||
/dev.db
|
||||
/dev.db-journal
|
||||
/dev.db-shm
|
||||
/dev.db-wal
|
||||
/data/
|
||||
/study.db
|
||||
/study.db-journal
|
||||
/study.db-shm
|
||||
/study.db-wal
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
|
|
|||
23
Dockerfile
23
Dockerfile
|
|
@ -1,19 +1,11 @@
|
|||
# ---- base ----
|
||||
FROM node:22-slim AS base
|
||||
RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---- deps ----
|
||||
FROM base AS deps
|
||||
FROM node:22-slim AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# ---- production dependencies ----
|
||||
FROM deps AS runtime-deps
|
||||
RUN npm prune --omit=dev
|
||||
RUN npm install
|
||||
|
||||
# ---- builder ----
|
||||
FROM base AS builder
|
||||
FROM node:22-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
|
@ -21,13 +13,13 @@ RUN npx prisma generate
|
|||
RUN npm run build
|
||||
|
||||
# ---- runner ----
|
||||
FROM base AS runner
|
||||
FROM node:22-slim AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3726
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV DATABASE_URL="file:/app/data/study.db"
|
||||
|
||||
RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||
RUN useradd --system --create-home appuser && mkdir -p /app/data && chown -R appuser:appuser /app
|
||||
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
|
|
@ -35,13 +27,10 @@ COPY --from=builder /app/.next/static ./.next/static
|
|||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/prisma.config.ts ./
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
COPY --from=runtime-deps /app/node_modules ./node_modules
|
||||
RUN npm install prisma@^7.8.0
|
||||
COPY docker-entrypoint.sh ./
|
||||
RUN chmod +x docker-entrypoint.sh
|
||||
# Running as root to avoid permission denied on Unraid host-mounted volumes
|
||||
EXPOSE 3726
|
||||
VOLUME ["/app/data"]
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=6 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:3726/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
|
|
|
|||
81
README.md
81
README.md
|
|
@ -1,83 +1,4 @@
|
|||
Study Desk is a self-hosted, single-user study application.
|
||||
|
||||
## Local development
|
||||
|
||||
```powershell
|
||||
npm.cmd ci
|
||||
npx.cmd prisma generate
|
||||
npm.cmd run dev
|
||||
```
|
||||
|
||||
The automated test harness always creates a uniquely named database under
|
||||
`.test-databases/`, applies committed migrations, and removes the database after
|
||||
the run. It refuses `dev.db`, `/app/data/study.db`, existing databases, and paths
|
||||
that are not explicitly test-named.
|
||||
|
||||
## Production container
|
||||
|
||||
Set a unique session secret of at least 32 characters. Production startup fails
|
||||
before migrations or the HTTP server when the secret is missing, short, or the
|
||||
published development fallback.
|
||||
|
||||
```powershell
|
||||
$env:SESSION_SECRET = '<at-least-32-random-characters>'
|
||||
docker compose -f docker-compose.yml up --build
|
||||
```
|
||||
|
||||
The host listens on `http://localhost:3000`; the container listens on port 3726.
|
||||
Development Compose is intentionally explicit and is never auto-merged:
|
||||
|
||||
```powershell
|
||||
docker compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
For first-time production setup, either provide an Argon2 encoded
|
||||
`ADMIN_PASSWORD_HASH`, or temporarily set `ALLOW_INITIAL_SETUP=true` for the
|
||||
one-time setup request. Remove the flag after setup. Production HTTP password
|
||||
reset initiation is unavailable; run this on the server instead:
|
||||
|
||||
```powershell
|
||||
npm.cmd run auth:reset
|
||||
```
|
||||
|
||||
If TLS terminates at a trusted reverse proxy, forward requests only from that
|
||||
proxy and set `SECURE_COOKIES=true`. Leave it false for intentional plain HTTP;
|
||||
Secure cookies cannot be used over plain HTTP.
|
||||
|
||||
## Database backup, migration adoption, and restore
|
||||
|
||||
Never run migration tests against the only copy of a study database. Create a
|
||||
lock-safe SQLite backup through the backup API and verify it before deployment:
|
||||
|
||||
```powershell
|
||||
$env:DATABASE_URL = 'file:./data/study.db'
|
||||
npm.cmd run db:backup -- --output ./backups/study-before-upgrade.db
|
||||
npm.cmd run db:preflight
|
||||
npx.cmd prisma migrate deploy
|
||||
```
|
||||
|
||||
The material-group preflight reports one of four states:
|
||||
|
||||
- `APPLY`: migration history is complete and the group schema is absent; run
|
||||
`prisma migrate deploy` normally.
|
||||
- `ADOPT`: prior migrations are tracked and the database exactly matches the
|
||||
intended schema of one or more unrecorded committed migrations. This includes
|
||||
recovery from P3018 when an already schema-pushed table or column exists.
|
||||
After reviewing the verified backup, explicitly run
|
||||
`npm run db:preflight -- --resolve --backup <backup-path>`.
|
||||
- `CURRENT`: migration and schema already agree.
|
||||
- `CONFLICT`: partial or unknown state. Stop and recover manually; do not use
|
||||
`db push`, reset, edit applied migrations, or mark anything applied.
|
||||
|
||||
The production entrypoint runs this preflight before `prisma migrate deploy`.
|
||||
It refuses `ADOPT` and `CONFLICT` states without changing migration history;
|
||||
adoption always remains an explicit backup-gated operator action.
|
||||
|
||||
Restore drill: stop Study Desk, keep the damaged database as evidence, restore
|
||||
the verified backup to a new path, run SQLite `integrity_check` plus
|
||||
`npm run db:preflight`, start the same prior application image against the
|
||||
restored path, and compare class/deck/quiz counts plus representative content.
|
||||
Only swap the production path after those checks pass.
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
# Confirmed Findings
|
||||
|
||||
Severity: Critical / High / Medium / Low / Observation. Confidence: Confirmed / High-confidence inference / Unverified risk.
|
||||
Full structured detail for every finding (files, functions, scenario, expected/actual, root cause, impact, evidence, fix direction, complexity, regression test, runtime-confirmation flag) is in `FINDINGS.json` (validated). Source IDs from specialist auditors are in parentheses.
|
||||
|
||||
| ID | Severity | Confidence | Title | Area |
|
||||
|---|---|---|---|---|
|
||||
| FIN-01 (DBAUD-01, GRP-01) | **Critical** | Confirmed | Schema/migration drift: `MaterialGroup` table + `groupId` columns never migrated — fresh deployments' databases are incompatible with the app (verified: `no such column: Deck.groupId`, `no such table: MaterialGroup`) | DB/migrations |
|
||||
| FIN-02 (AUTH-01, OPS-03) | **Critical** | High-confidence inference | Hardcoded fallback `SESSION_SECRET` (`"dev-session-secret-change-in-production-must-be-32-chars"`) in `src/lib/auth.ts:15` + `src/proxy.ts:8-9`; empty env var in shipped compose → forgeable session cookie → full auth bypass | Auth/deploy |
|
||||
| FIN-03 (QUIZ-01, ADV-01) | **High** | Confirmed | In-viewer "Retake Missed" never sets `retakeIds` prop → persisted as a full attempt with a wrong, lower score; pollutes SEQUENTIAL progress | Quiz |
|
||||
| FIN-04 (CARD-01, IMPT-01, QUIZ-06) | **High** | Confirmed | Stale card ids in saved progress never filtered on resume (`filterAndClampOrder` dead code) → deleting a card mid-session bricks the flashcard study session | Flashcards |
|
||||
| FIN-05 (OPS-01) | **High** | Confirmed | Port mismatch: container listens on 3726, compose publishes 3000:3000 → production deployment unreachable | Deploy |
|
||||
| FIN-06 (OPS-06) | **High** | High-confidence inference | No `.dockerignore`: Windows-host builds inject win32 native modules (better-sqlite3, argon2) into the Linux image | Deploy |
|
||||
| FIN-07 (OPS-08) | **High** | Confirmed | `docker-compose.override.yml` auto-merges on plain `docker compose up` → production command silently runs dev mode | Deploy |
|
||||
| FIN-08 (AUTH-04, ADV-09) | Medium | High-confidence inference | First-login provisioning takeover; `ADMIN_PASSWORD_HASH` env is dead config | Auth |
|
||||
| FIN-09 (AUTH-02) | Medium | High-confidence inference | Proxy auth bypass for any path containing `.` (latent today; would expose any future non-UUID-id route) | Auth |
|
||||
| FIN-10 (AUTH-03) | Medium | High-confidence inference | Password-reset abuse: token overwrite DoS, spoofable `x-forwarded-for` rate limits, unthrottled `complete` | Auth |
|
||||
| FIN-11 (QUIZ-02) | Medium | High-confidence inference | Attempt route stores unvalidated `answersJson`; duplicate option ids inflate SATA credit; malformed shapes → 500 | Quiz |
|
||||
| FIN-12 (QUIZ-03, FE-04) | Medium | Confirmed | Quiz Finish double-submit creates duplicate attempts; failure path silent | Quiz/FE |
|
||||
| FIN-13 (QUIZ-04) | Medium | High-confidence inference | Progress PATCH can land after Finish's DELETE → stale "Continue" row for a completed quiz | Quiz |
|
||||
| FIN-14 (CARD-02, FE-05, QUIZ-09) | Medium | High-confidence inference | Fire-and-forget, unsequenced progress autosaves: out-of-order PATCHes regress resume state; multi-tab last-write-wins | Flashcards/Quiz |
|
||||
| FIN-15 (CARD-03, ADV-07) | Medium | Confirmed | Completed flashcard session never persisted as complete → resume re-shows the last graded card | Flashcards |
|
||||
| FIN-16 (CARD-04) | Medium | Confirmed | SRS review endpoint enforces membership only — no due-date or `newCardsPerDay` enforcement server-side | SRS |
|
||||
| FIN-17 (CARD-05, ADV-02) | Medium | Confirmed | Unbounded DB scans: whole `StudyActivity` table per fetch; all SRS card states per set; Navbar polls every 60 s | Perf/DB |
|
||||
| FIN-18 (FE-01, GRP-03, FE-02, GRP-05) | Medium | Confirmed | Library optimistic mutations are fire-and-forget (no `res.ok`, no rollback); cross-group reorder omits origin group from payload | FE/Groups |
|
||||
| FIN-19 (GRP-02, CARD-07, AUTH-06) | Medium | Confirmed | Reorder/create endpoints trust client `groupId`/`sortOrder` — no class/type/existence validation; cross-class group membership can leak content into another class's share link | Groups |
|
||||
| FIN-20 (FE-03) | Medium | Confirmed | Shared quiz session resets and options reshuffle on any parent re-render (unstable inline `quiz` prop) | FE |
|
||||
| FIN-21 (IMPT-02, FE-08, QUIZ-05, ADV-10) | Medium | Confirmed | `/api/progress` PATCH completely unvalidated; unguarded client `JSON.parse` of persisted progress crashes pages | Quiz/FE |
|
||||
| FIN-22 (OPS-02) | Medium | High-confidence inference | Entrypoint migrate-deploy chain fragile: crash loop on conflicting DB state; runner prisma CLI install unverified/unpinned | Deploy |
|
||||
| FIN-23 (OPS-05) | Medium | Confirmed | Non-reproducible installs: `npm install` (not `ci`); unpinned `prisma@^7.8.0` re-install re-resolves the whole tree in the runner | Deploy |
|
||||
| FIN-24 (OPS-07) | Medium | Confirmed | No healthcheck, no backup, no documented recovery | Deploy |
|
||||
| FIN-25 (OPS-10, TEST-10) | Medium | Confirmed | CI builds/pushes without tests, lint, prisma validate, or container smoke test — deployment-breaking defects ship green | CI |
|
||||
| FIN-26 (QUIZ-07) | Low | Confirmed | SATA scoring divides by `correctIds.length` with no zero-guard → NaN | Quiz |
|
||||
| FIN-27 (QUIZ-08) | Low | Confirmed | Historical review/category breakdown recompute scores from current content, not stored attempt | Quiz |
|
||||
| FIN-28 (IMPT-03) | Low | Confirmed | Card add/edit endpoints bypass Zod: empty strings storable; CreateTab silently drops incomplete cards | Imports |
|
||||
| FIN-29 (IMPT-04) | Low | Confirmed | No payload size/string-length/array-length caps on import schemas/routes; whitespace-only names accepted | Imports |
|
||||
| FIN-30 (IMPT-05) | Low | Confirmed | SATA constraint mismatch: instructions say ≥2 correct, schema enforces ≥1 | Imports |
|
||||
| FIN-31 (IMPT-06) | Low | Confirmed | Unvalidated `name`/`groupId` override fields on import POST routes → 500; PATCH routes mask failures as 404 | Imports |
|
||||
| FIN-32 (GRP-04) | Low | Confirmed | Group deletion leaves duplicate sortOrder values in Uncategorized; no unique constraint/tie-break | Groups |
|
||||
| FIN-33 (GRP-06) | Low | Confirmed | Keyboard users cannot move items between groups (empty `handleDragOver`) | Groups/a11y |
|
||||
| FIN-34 (CARD-08) | Low | High-confidence inference | Restart deletes progress fire-and-forget; slow DELETE can remove the fresh session's progress row | Flashcards |
|
||||
| FIN-35 (CARD-09) | Low | Confirmed | Concurrent first review of same new card → P2002 → generic 500 instead of 409 | SRS |
|
||||
| FIN-36 (CARD-10) | Low | High-confidence inference | "Previous card" during the 350 ms grade animation races the pending timeout | Flashcards |
|
||||
| FIN-37 (CARD-11) | Low | Confirmed | SRS set page never refreshes on focus → stale membership after deck deletion in another tab | SRS/FE |
|
||||
| FIN-38 (CARD-06) | Low | Confirmed | Day boundary hardcoded to Arizona (UTC-7): "today"/new-card limit/streak roll over at 07:00 UTC | SRS |
|
||||
| FIN-39 (AUTH-05) | Low | Confirmed | Proxy `destroy()` cookie-clear lost on redirect; layout check ignores `sessionGeneration` | Auth |
|
||||
| FIN-40 (AUTH-08) | Low | Confirmed | Session cookie Secure flag off in shipped compose (plain HTTP, no TLS story) | Auth/deploy |
|
||||
| FIN-41 (DBAUD-02, AUTH-07) | Low | Confirmed | `/api/share` accepts arbitrary `targetType` → repeatable junk all-NULL `ShareLink` rows | Auth/DB |
|
||||
| FIN-42 (DBAUD-03) | Low | High-confidence inference | `dev.db` committed in git history (8 commits), remains in blobs on the LAN remote | Repo hygiene |
|
||||
| FIN-43 (OPS-09) | Low | Confirmed | `.gitignore` gaps (`data/`, `study.db*`); scratch files `out.css`/`temp.css`/`test.css` tracked | Repo hygiene |
|
||||
| FIN-44 (FE-07, ADV-04) | Low | Confirmed | Dashboard `fetchClasses` no `.catch`/`res.ok` → unhandled rejection, misleading empty state (or render crash) | FE |
|
||||
| FIN-45 (FE-09) | Low | Confirmed | Effect fetches without `.catch` in GenerateTab and ShareMenu | FE |
|
||||
| FIN-46 (FE-10) | Low | High-confidence inference | Cross-class navigation fetch race renders wrong-class data; module cache never invalidated | FE |
|
||||
| FIN-47 (ADV-03) | Low | Confirmed | Logout has no error handling — failed logout strands the user | FE |
|
||||
| FIN-48 (ADV-05) | Low | Confirmed | `slugify` can yield an empty slug (unreachable class); renames never update the URL slug | Classes |
|
||||
| FIN-49 (ADV-08) | Low | High-confidence inference | Shared-viewer localStorage keys item-scoped, not token-scoped → session bleed across tokens | Sharing/FE |
|
||||
| FIN-50 (GRP-07) | Observation | Confirmed | Group `sortOrder` uses inverted desc convention with unvalidated PATCH — latent trap | Groups |
|
||||
| FIN-51 (FE-11) | Observation | Confirmed | Collapsed-groups state read in effect → one-frame expand flash | FE |
|
||||
| FIN-52 (AUTH-10) | Observation | Confirmed | Shared quiz links ship the full answer key to anonymous viewers — by design, worth a warning | Sharing |
|
||||
| FIN-53 (TEST-01) | Low | Confirmed | False-confidence test: "distinct valid state for every rating" never asserts distinctness | Tests |
|
||||
|
||||
## Notes on consolidation
|
||||
|
||||
- Duplicate findings across specialist reports were merged under one root cause (see `sourceIds`).
|
||||
- FE-06 ("all /api/* routes unauthenticated") was **refuted** by the adversarial review — `src/proxy.ts` IS the compiled middleware and enforces session checks; the residual truth is FIN-09 (dot-bypass). See REJECTED_FINDINGS.md.
|
||||
- OPS-02's fresh-volume crash-loop framing was weakened by the adversarial review (on a fresh volume `migrate deploy` succeeds; the app then fails per FIN-01) — reframed as FIN-22.
|
||||
- FIN-18 severity downgraded from the frontend auditor's High to Medium after adversarial review (no visible break for a single user; silent divergence self-heals on reload).
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
# Coverage Map
|
||||
|
||||
Mapping of repository areas → audit status → specialist report reference. Statuses: done / done+verified (parent-verified with runtime evidence) / partial.
|
||||
|
||||
| Area | Files | Auditor | Status | Report ref |
|
||||
|---|---|---|---|---|
|
||||
| Prisma schema/migrations/transactions/cascades | prisma/schema.prisma, prisma/migrations/*, prisma.config.ts, src/lib/db.ts, services | DB specialist (task-1) | done+verified (drift reproduced) | sa_20260806_054030_000000000_5ed877563a0d |
|
||||
| Quiz scoring/attempts/history/retakes/progress | src/lib/scoring.ts, quizService, progressService, quizzes/*, api/quizzes/**, api/progress | Quiz specialist (task-2) | done+verified (server scoring path re-checked) | sa_20260806_054030_000000000_e75a8ce575f9 |
|
||||
| Flashcards/SRS/progress/deletion/ordering | flashcards/*, spaced-repetition/*, cardService, deckService, spacedRepetitionService, api/cards|decks|spaced-repetition-sets|progress | Flashcard specialist (task-3) | done | sa_20260806_054030_000000000_a222804c133c |
|
||||
| Auth/protected routes/API authz/sharing | src/proxy.ts, lib/auth.ts, authService, shareService, shareMetadata, api/auth/**, api/share, shared/*, all api routes | Auth specialist (task-4) | done+verified (proxy + fallback secret read directly) | sa_20260806_054030_000000000_aab0c59dbad3 |
|
||||
| Imports/exports/generation/Zod/malformed input | lib/validation/*, jsonRepair, components/import/*, api/decks|quizzes|material-groups|settings | Import specialist (task-5) | done | sa_20260806_054030_000000000_5e72c2713989 |
|
||||
| Material groups/library/drag-drop/deletion/orphans | api/material-groups/**, reorder routes, library pages, classService | Groups specialist (task-6) | done | sa_20260806_054030_000000000_9ae1f3dfa282 |
|
||||
| Frontend state/refresh/races/localStorage/boundaries | all components + pages + shared viewers | Frontend specialist (task-7) | done | sa_20260806_054030_000000000_0e17fd155702 |
|
||||
| Test quality/coverage gaps | *.test.ts, vitest.config.ts, package.json, CI workflow | Test specialist (task-8) | done | sa_20260806_054030_000000000_cd3652deb3f7 |
|
||||
| Docker/startup/env/scripts/production/recovery | Dockerfile, docker-entrypoint.sh, compose files, package.json, prisma.config.ts, next.config.ts, .gitignore, CI | Ops specialist (task-9) | done | sa_20260806_054030_000000000_f49cdaacaabf |
|
||||
| Adversarial review (challenge Critical/High + missed issues) | (all of the above + lightly-covered files) | Adversarial specialist (task-10) | done — 9/10 challenged findings confirmed/weakened, FE-06 refuted, 10 new issues added | sa_20260806_055608_000000000_c550c742d5dc |
|
||||
|
||||
## Parent-level verification performed (see VERIFICATION_LOG.md and audit-results/tmp/)
|
||||
|
||||
- `npx prisma validate` — PASS
|
||||
- `npm test` — PASS (30 tests: 20 arcade + 10 spacedRepetition)
|
||||
- `npm run lint` — 28 baseline problems (9 errors / 19 warnings), all pre-existing
|
||||
- `npm run build` — PASS (route table incl. "ƒ Proxy (Middleware)")
|
||||
- `prisma migrate deploy` on fresh temp DB — PASS (creates DB **missing** MaterialGroup/groupId → FIN-01 drift confirmed)
|
||||
- Temp DB introspection (`audit-results/tmp/inspect-db.cjs`) — `MaterialGroup` table and `groupId` columns absent
|
||||
- Prisma-shaped SQL against temp DB (`audit-results/tmp/reproduce-drift.cjs`) — `no such column: Deck.groupId`, `no such table: MaterialGroup`, `no such column: ShareLink.groupId`
|
||||
- `FINDINGS.json` validity — PASS (53 findings; validator `audit-results/tmp/validate-findings.cjs`)
|
||||
|
||||
## Coverage gaps (areas NOT fully audited)
|
||||
|
||||
1. **Arcade feature internals** — intentionally out of scope (may be removed). Only checked for shared-DB/build/deploy impact (Arcade models/indexes verified in migrations).
|
||||
2. **Real browser interaction** — no browser available in the audit environment: drag-drop, resume flows, share pages, keyboard a11y, theme behavior were code-verified only; findings needing runtime confirmation are flagged in UNVERIFIED_RISKS.md.
|
||||
3. **Container build/run** — docker CLI unavailable: OPS-02/FIN-22, FIN-06, FIN-24 need a container run to observe exact failure modes.
|
||||
4. **git history deep-dive** — limited to drift commit (7af0935), dev.db commits, and migration commits; other commits not diffed line-by-line.
|
||||
5. **Committed dev.db blob contents** — could not be fully enumerated (no sqlite3 CLI; blob grep only; no admin_password hash found in checked blobs).
|
||||
6. **Live dev.db** — none exists in the workspace; runtime behavior on a populated database (perf findings FIN-17) not measured.
|
||||
7. **Network/remote behavior** — the Forgejo remote and CI execution were not reachable; CI findings are static analysis of `.forgejo/workflows/build.yml`.
|
||||
8. **External services** — none (app calls no LLM/third-party APIs).
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
# Study Desk — Overnight Read-Only Audit: Executive Summary
|
||||
|
||||
Status: **COMPLETE** (2026-08-06)
|
||||
|
||||
## Scope and method
|
||||
|
||||
Comprehensive read-only audit of the Study repository (Next.js 16 / React 19 / Prisma 7 / SQLite, self-hosted single-user study app). Ten specialist subagents audited: (1) Prisma schema/migrations/transactions/cascades/drift, (2) quiz scoring/attempts/retakes/progress, (3) flashcards/SRS/progress/ordering, (4) auth/protected routes/API authorization/sharing, (5) imports/exports/generation/Zod validation, (6) material groups/library/drag-drop/deletion, (7) frontend state/races/localStorage/boundaries, (8) test quality, (9) Docker/env/production/recovery, (10) adversarial review challenging every Critical/High finding and hunting missed issues. The parent agent independently re-verified all Critical/High claims (code tracing, git history, temp-DB migration chain, DB introspection, Prisma-shaped SQL probes). The Arcade feature set was excluded per scope unless it affects the main app, shared dependencies, database integrity, build, or deployment.
|
||||
|
||||
## Constraints honored
|
||||
|
||||
- **No source code, tests, migrations, config, package files, lockfiles, docs, or databases modified.** Final `git status --short`: only untracked `.reasonix/` and `audit-results/`; `git diff --check` clean; `src/generated/prisma/` byte-identical before/after all prisma commands (sha256).
|
||||
- All reports and diagnostic artifacts live under `audit-results/` (reports + `tmp/`: `audit-migration.db`, `inspect-db.cjs`, `reproduce-drift.cjs`, `validate-findings.cjs`, `generated-before.sha256`).
|
||||
- Existing databases: none found in the workspace; the migration-chain test used a brand-new temp DB.
|
||||
|
||||
## Baseline verification
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `npm test` | ✅ PASS — 30 tests / 6 files (20 arcade out of scope, 10 spacedRepetition) |
|
||||
| `npm run lint` | ⚠️ 28 problems (9 errors, 19 warnings) — all pre-existing baseline |
|
||||
| `npm run build` | ✅ PASS — full route table incl. "ƒ Proxy (Middleware)" |
|
||||
| `npx prisma validate` | ✅ PASS |
|
||||
| Migration chain on fresh temp DB | ✅ PASS — 4/4 migrations apply, but the resulting DB is **missing** `MaterialGroup` + `groupId` columns (drift, see below) |
|
||||
|
||||
## Findings (53 total: 2 Critical, 5 High, 18 Medium, 25 Low, 3 Observation)
|
||||
|
||||
Full detail in `FINDINGS.json` (validated) and `CONFIRMED_FINDINGS.md`.
|
||||
|
||||
### Critical
|
||||
|
||||
1. **FIN-01 — Schema/migration drift (Confirmed, runtime-verified).** `prisma/schema.prisma` defines `MaterialGroup` and `Deck/QuizSet/ShareLink.groupId` (added by commit 7af0935, which also rewrote a committed `dev.db` via `prisma db push`) but **no migration creates them**. Every DB built by `prisma migrate deploy` — the `predev` hook and the Docker entrypoint — lacks these objects; the generated client then fails every Deck/QuizSet/ShareLink/MaterialGroup query (`no such column: Deck.groupId`, `no such table: MaterialGroup` — reproduced against a temp DB). **Any fresh install/deploy is non-functional.** The app only works on the developer's db-push-synced local database.
|
||||
2. **FIN-02 — Hardcoded session-secret fallback (High-confidence inference).** `src/lib/auth.ts:15` and `src/proxy.ts:8-9` fall back to the public constant `"dev-session-secret-change-in-production-must-be-32-chars"` whenever `SESSION_SECRET` is unset — and the shipped `docker-compose.yml` passes `${SESSION_SECRET}`, which is empty by default. Anyone with the source can forge an iron-session cookie (`{isAuthenticated:true, sessionGeneration:1}`) and fully bypass authentication on such deployments.
|
||||
|
||||
### High
|
||||
|
||||
3. **FIN-03 — "Retake Missed" persists as a full attempt with a wrong lower score** (in-viewer retake never sets the `retakeIds` prop; server scores all unanswered questions as 0) and pollutes SEQUENTIAL progress.
|
||||
4. **FIN-04 — Deleting a card mid-session bricks the flashcard study session** (`filterAndClampOrder` is dead code; resume never filters stale ids; no skip UI).
|
||||
5. **FIN-05 — Production compose is unreachable**: container listens on 3726 (`ENV PORT=3726`), compose publishes 3000:3000.
|
||||
6. **FIN-06 — No `.dockerignore`**: Windows-host builds inject win32 native modules (better-sqlite3, argon2) into the Linux image.
|
||||
7. **FIN-07 — `docker-compose.override.yml` auto-merges**: plain `docker compose up` silently runs dev mode in production.
|
||||
|
||||
### Medium (selected)
|
||||
|
||||
First-login provisioning takeover + dead `ADMIN_PASSWORD_HASH` (FIN-08); proxy auth bypass for dot-containing paths (FIN-09); password-reset abuse (FIN-10); unvalidated attempt `answersJson` (FIN-11); finish double-submit duplicates attempts (FIN-12); progress PATCH/DELETE race (FIN-13); unsequenced progress autosaves (FIN-14); completed session resumes on last card (FIN-15); SRS review endpoint lacks due/new-card-limit enforcement (FIN-16); unbounded DB scans (FIN-17); library optimistic mutations without rollback (FIN-18); unvalidated `groupId`/`sortOrder` incl. cross-class group membership (FIN-19); shared-quiz reshuffle on re-render (FIN-20); unvalidated `/api/progress` + unguarded `JSON.parse` crashes (FIN-21); entrypoint migrate fragility (FIN-22); non-reproducible installs (FIN-23); no healthcheck/backup (FIN-24); CI ships without tests/lint/prisma/smoke gates (FIN-25).
|
||||
|
||||
### Low / Observation
|
||||
|
||||
25 Low (e.g. SATA div-by-zero NaN, historical score recompute, card-edit endpoints without Zod, no import size caps, sortOrder duplicates after group deletion, keyboard cross-group moves impossible, restart DELETE race, P2002 → 500, git history containing `dev.db`, `.gitignore` gaps, dashboard fetch crash, empty slug from `slugify`, shared-viewer localStorage key collisions) and 3 Observations (group sortOrder desc convention, collapse flash, shared quizzes ship the answer key by design).
|
||||
|
||||
## Verification highlights
|
||||
|
||||
- FIN-01 disproved attempts: checked whether the client avoids selecting `groupId` (it doesn't — generated `Deck.ts` includes it in every payload), whether pages swallow errors (they don't), and whether any migration creates the objects (grep = 0 hits). Confirmed via live probes instead: see `audit-results/tmp/reproduce-drift.cjs` output in VERIFICATION_LOG.md.
|
||||
- Adversarial review **refuted** the frontend auditor's "no auth on API routes" claim (proxy is compiled middleware — verified in `.next` artifacts), **weakened** the entrypoint crash-loop framing (fresh-volume `migrate deploy` succeeds), and downgraded the optimistic-reorder severity. It independently confirmed all other Critical/High findings and added 10 missed issues, merged into the final list.
|
||||
|
||||
## Repository areas NOT fully audited
|
||||
|
||||
1. Arcade feature internals (out of scope; shared-DB/build/deploy impact only — verified in migrations).
|
||||
2. Real browser interaction (no browser available): drag-drop, resume flows, share pages, keyboard a11y, themes — code-verified; flagged for runtime confirmation in UNVERIFIED_RISKS.md.
|
||||
3. Container build/run (docker CLI unavailable): FIN-06, FIN-22, FIN-24 need a container run.
|
||||
4. Deep git-history diff of every commit; full content audit of the committed `dev.db` blobs (no sqlite3 CLI; no credentials found in the checked blobs).
|
||||
5. Runtime performance on a populated database (FIN-17 not measured).
|
||||
6. CI/remote execution (Forgejo pipeline analyzed statically).
|
||||
|
||||
## Recommended priority (when fixes are authorized)
|
||||
|
||||
1. **FIN-01** — generate and commit the missing migration (`prisma migrate dev --name add_material_groups`); add a CI drift gate. Everything else depends on a working fresh deployment.
|
||||
2. **FIN-02 / FIN-05 / FIN-06 / FIN-07** — harden the deployment path: require `SESSION_SECRET`, fix the port mapping, add `.dockerignore`, rename the dev override, and smoke-test the image in CI.
|
||||
3. **FIN-03 / FIN-04** — the two most user-visible application bugs (wrong retake scores; stuck flashcard sessions).
|
||||
4. Then the Medium cluster (validation, races, SRS enforcement, progress handling) and finally Low items + TEST_GAPS.md.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,15 +0,0 @@
|
|||
# Rejected Findings
|
||||
|
||||
Findings that were investigated and disproven, or removed as duplicate/stylistic/insignificant, with the reason.
|
||||
|
||||
| ID | Original claim | Rejection reason |
|
||||
|---|---|---|
|
||||
| FE-06 | "All `/api/*` routes are unauthenticated (no middleware, no per-route checks) — curl without a session cookie returns data" | **Refuted by adversarial review + parent verification.** `src/proxy.ts` IS the compiled Next.js 16 middleware (verified in `.next/server/functions-config-manifest.json`, `middleware.js`, and the compiled chunk; build output shows "ƒ Proxy (Middleware)"). It decrypts the iron-session cookie and enforces `isAuthenticated` + `sessionGeneration` on every path except `/login`, `/api/auth`, `/shared`, `/_next*`, `/favicon*`, and paths containing `.`. The auditor searched for `middleware.ts` (the Next 15 name) and missed `proxy.ts`. The residual truth is the dot-bypass (FIN-09). The claim that "no frontend code handles 401s because there is no auth" is also wrong — there is no 401 because the proxy redirects to /login. |
|
||||
| QUIZ-06 (standalone) | "Quiz resume with stale question ids crashes the viewer" | Merged into FIN-04. The quiz-side path is currently unreachable (no question editing/deletion exists in the app; quiz deletion cascades progress), so it is a latent variant of the same root cause (dead `filterAndClampOrder`), not a separate live defect. |
|
||||
| OPS-02 (original framing) | "Fresh-volume deployment crash-loops because `migrate deploy` fails" | **Weakened by adversarial review.** On a fresh volume `prisma migrate deploy` *succeeds* (verified against a temp DB); the fresh-volume failure is FIN-01 (schema drift → app 500s while the container runs). The crash loop requires a secondary trigger (db-push DB, tampering, lock). Reframed as FIN-22 with the crash-loop path demoted. |
|
||||
| FE-01 (original severity) | Optimistic drag-drop reorder without rollback rated High | **Severity downgraded to Medium by adversarial review.** Mechanics confirmed (fire-and-forget fetch, in-place mutation, origin group omitted from cross-group payload) but impact analysis: sortOrder gaps only, silent divergence self-heals on reload, no visible break for a single user. Merged into FIN-18. |
|
||||
| AUTH-06 (standalone) | "Cross-class group membership via reorder API exposes another class's content" | Merged into FIN-19 (same root cause: `groupId`/`sortOrder` never validated server-side). The sharing-exposure angle is recorded as impact of FIN-19. |
|
||||
| DBAUD-04 | "Relative SQLite path resolution may cause dev dual-DB split-brain" | **Disproven.** CLI (`prisma.config.ts`) and app adapter both resolve `file:./dev.db` cwd-relative; `predev` and the app run from the project root → same file. Docker uses absolute `file:/app/data/study.db`. Recorded as verified-safe in UNVERIFIED_RISKS.md. |
|
||||
| DBAUD-05/06/07, QUIZ-08-verdicts, GRP-01-verdicts (various) | "Cascades broken / transactions partial / StudyProgress duplicates via NULL tricks / id regeneration on card edit / XSS via markdown / client-server boundary violations" | **Disproven** by the respective auditors with evidence: FKs enforced by compile default; nested creates implicit-transactional; service-level guards block duplicates; PATCH edits in place (ids preserved); no `rehype-raw`; no server-module imports in client components. Recorded as verified-safe. |
|
||||
| FE-11 (as bug) | "Collapsed-groups expand flash" | Retained only as Observation (FIN-51) — cosmetic, no data impact. |
|
||||
| Various | Style preferences, harmless duplication, generic best practices, speculative micro-optimizations, Arcade-only issues | Excluded per audit scope rules. |
|
||||
|
|
@ -1,340 +0,0 @@
|
|||
# Study Desk audit evaluation and remediation plan
|
||||
|
||||
Date: 2026-08-06
|
||||
Scope: independent evaluation of `CONFIRMED_FINDINGS.md`, `FINDINGS.json`, the supporting audit reports, and the current repository source.
|
||||
Change policy for this evaluation: this plan is the only new file; no application source, tests, migrations, configuration, generated files, database, or existing audit artifact was changed.
|
||||
|
||||
## Executive verdict
|
||||
|
||||
The audit is credible and the central findings hold up against the current source. The most urgent problems are not cosmetic:
|
||||
|
||||
1. The committed migration chain does not create `MaterialGroup` or the three `groupId` columns, while the generated Prisma client selects those fields. A database created only with `prisma migrate deploy` is incompatible with the app.
|
||||
2. Production authentication can use a public fallback session secret, making a deployment with an empty `SESSION_SECRET` forgeable.
|
||||
3. The in-viewer quiz retake flow loses the retake scope and records the retake as a full, incorrectly scored attempt.
|
||||
4. Flashcard resume trusts stale IDs and unguarded JSON, so deletion or malformed progress can leave the study viewer blank or crash it.
|
||||
5. The production container path is presently unreliable: the port mapping is wrong without the development override, the conventional override silently changes normal Compose behavior, the build context can include host `node_modules`, and the runner installs a ranged Prisma CLI outside the lockfile.
|
||||
|
||||
I agree with most of the remaining findings as defects or resilience gaps, but not always with their severity or proposed remedy. In particular:
|
||||
|
||||
- FIN-04 needs more than wiring in the existing `filterAndClampOrder`: that helper does not preserve the logical current card when a stale ID occurs before the saved index, and it turns a completed index back into the last card.
|
||||
- FIN-05 should normally use `3000:3726`, preserving the documented host port while mapping it to the actual container port.
|
||||
- FIN-22 should not be fixed with an automatic migration retry loop. Schema conflicts should fail fast with actionable diagnostics and a documented recovery path.
|
||||
- FIN-27 is more important than Low because changing quiz content can rewrite the meaning of persisted history.
|
||||
- FIN-34 is only partially present: the dedicated deck page already awaits both DELETE requests, but the library start/restart path fires them without waiting.
|
||||
- FIN-38 is not currently a defect for this Arizona-based installation. It becomes a portability requirement only if the app is meant to support a configurable local day boundary.
|
||||
- FIN-42 does not justify a destructive history rewrite without evidence that the old database contained sensitive data and without coordinating every clone/remote.
|
||||
- FIN-50 is an intentional convention. Newest named groups must remain first and Uncategorized must remain last; validation should make that convention harder to corrupt.
|
||||
- FIN-52 is an architectural consequence of local grading in an anonymous read-only viewer. It merits a sharing warning, not removal of the answer key unless the product adopts server-side anonymous grading.
|
||||
|
||||
## Finding-by-finding disposition
|
||||
|
||||
Priority definitions: P0 = release/deployment blocker; P1 = next remediation batch; P2 = important hardening; P3 = worthwhile backlog; Conditional = verify a stated condition before changing behavior; No separate fix = intentional behavior or covered by another item.
|
||||
|
||||
| ID | Verdict | Worth fixing? | Priority and disposition |
|
||||
|---|---|---:|---|
|
||||
| FIN-01 | Agree | Yes | **P0.** Add the missing migration plus a safe adoption runbook for already-`db push`-synchronized databases. |
|
||||
| FIN-02 | Agree, conditional on an unset/blank production secret | Yes | **P0.** Production must fail before serving requests; development may use an explicitly development-only value. |
|
||||
| FIN-03 | Agree | Yes | **P1.** Make attempted-question scope explicit and derive partial-retake status on the server. |
|
||||
| FIN-04 | Agree; proposed helper is insufficient as written | Yes | **P1.** Normalize order, index, and results while preserving the logical current card and completed state. |
|
||||
| FIN-05 | Agree | Yes | **P0.** Map host 3000 to container 3726 and smoke-test it. |
|
||||
| FIN-06 | Agree with the mechanics; exact runtime failure is unconfirmed | Yes | **P0.** Add `.dockerignore` and prove native modules load in the Linux image. |
|
||||
| FIN-07 | Agree | Yes | **P0.** Rename the development override and document explicit dev/prod commands. |
|
||||
| FIN-08 | Partly agree: first-login setup is intentional, accidental exposure and dead config are real | Yes | **P1.** Honor a pre-provisioned admin hash or require an explicit one-time setup mode. |
|
||||
| FIN-09 | Agree; latent with current IDs | Yes | **P1.** Replace the broad dot bypass with exact public/static path rules. |
|
||||
| FIN-10 | Agree | Yes | **P1.** Prevent token overwrite, remove spoofable per-IP trust, and make reset initiation a local/admin operation. |
|
||||
| FIN-11 | Agree | Yes | **P1.** Validate the whole attempt envelope, question scope, option ownership, array uniqueness, and types. |
|
||||
| FIN-12 | Agree | Yes | **P1.** Add an in-flight guard, disable Finish, and surface failure/retry state. |
|
||||
| FIN-13 | Agree as a credible timing race | Yes | **P1.** Persist the attempt and clear full-attempt progress in one server transaction. |
|
||||
| FIN-14 | Agree as a credible timing race | Yes | **P2.** Add session identity and monotonic revisions, not only client debouncing. |
|
||||
| FIN-15 | Agree | Yes | **P1.** Persist `currentIndex === order.length` and restore the summary state. |
|
||||
| FIN-16 | Agree; impact is lower in a protected single-user app | Yes | **P2.** Accept only the queue-eligible card, including the intended learn-ahead rule. |
|
||||
| FIN-17 | Agree that the queries are unbounded; user impact is unmeasured | Conditional | **P3.** Benchmark realistic data first, then optimize queries that exceed the budget. |
|
||||
| FIN-18 | Agree | Yes | **P2.** Stop mutating shared objects, capture the origin group before mutation, await writes, and rollback/refetch on error. |
|
||||
| FIN-19 | Agree | Yes | **P1.** Enforce class/type/group membership and server-owned ordering; retain a share-page defense. |
|
||||
| FIN-20 | Agree | Yes | **P1.** Give the viewer a stable session input so unrelated parent renders cannot reset it. |
|
||||
| FIN-21 | Agree | Yes | **P1.** Validate progress writes and defensively normalize legacy/corrupt progress on reads. |
|
||||
| FIN-22 | Partly agree with the operational gap, not the retry remedy | No separate fix | Fail fast; cover CLI pinning in FIN-23 and recovery/health in FIN-24. Do not retry a conflicting migration automatically. |
|
||||
| FIN-23 | Agree | Yes | **P0.** Use `npm ci` and ship a lockfile-pinned Prisma CLI/runtime path. |
|
||||
| FIN-24 | Agree | Yes | **P1.** Add a schema-aware health check and tested backup/restore instructions. |
|
||||
| FIN-25 | Agree | Yes | **P0/P1.** Gate test, build, Prisma validation/drift, and container smoke. Stage lint until its known baseline is resolved. |
|
||||
| FIN-26 | Agree | Yes | **P1.** Return zero for an invalid zero-correct SATA question and test finite totals. |
|
||||
| FIN-27 | Agree; severity should be Medium | Yes | **P2.** Persist an immutable result/review snapshot for new attempts, with legacy fallback. |
|
||||
| FIN-28 | Agree | Yes | **P2.** Reuse a trimmed card-content schema and reject rather than silently drop incomplete Create-tab rows. |
|
||||
| FIN-29 | Agree | Yes | **P2.** Add reasonable string, array, and request-size limits with clear 400/413 errors. |
|
||||
| FIN-30 | Agree that the contract is inconsistent | Yes | **P2.** Make SATA imports require at least two correct options, matching the shipped generation instructions; preserve legacy stored data. |
|
||||
| FIN-31 | Agree | Yes | **P2.** Validate complete request envelopes and map only actual not-found errors to 404. |
|
||||
| FIN-32 | Agree | Yes | **P2.** Delete/reassign/renumber in one transaction and add deterministic item tie-breaks. |
|
||||
| FIN-33 | Agree | Yes | **P2.** Add an explicit keyboard-accessible “Move to group” action; do not depend on drag gestures. |
|
||||
| FIN-34 | Partly agree | Yes | **P2.** Fix the library path that does not await deletion; make deletion session-aware with FIN-14. |
|
||||
| FIN-35 | Agree | Yes | **P3.** Translate concurrent first-review uniqueness conflicts to 409 and refetch. |
|
||||
| FIN-36 | Agree as a credible timing race | Yes | **P2.** Disable navigation while grading and cancel/ignore stale animation callbacks. |
|
||||
| FIN-37 | Agree | Yes | **P3.** Refresh set/deck membership on focus and after local deck-change events. |
|
||||
| FIN-38 | Factually correct, but Arizona is the current intended boundary | Not now | Keep the existing behavior. Revisit only with an explicit portability requirement and a timezone setting/migration plan. |
|
||||
| FIN-39 | Agree about the lost clear-cookie response; layout impact is overstated because proxy checks generation | Yes | **P2.** Destroy the cookie on the actual redirect response and test its header. |
|
||||
| FIN-40 | Deployment-policy gap, not a bug on intentional HTTP | Yes, as documentation/config | **P1.** Document a TLS proxy profile and `SECURE_COOKIES=true`; do not enable Secure cookies on plain HTTP. |
|
||||
| FIN-41 | Agree | Yes | **P1.** Validate a strict target enum and target existence; reject impossible/null-target rows. |
|
||||
| FIN-42 | Agree that blobs exist; sensitivity is unproven | Conditional | Do not rewrite history now. Inventory the old DB offline before any public remote; rewrite only with explicit coordination if sensitive data is found. |
|
||||
| FIN-43 | Partly agree | Yes | **P3.** Ignore `data/` and production DB sidecars. Confirm ownership, then remove unreferenced CSS scratch files. Do not automatically ignore the audit plan. |
|
||||
| FIN-44 | Agree | Yes | **P2.** Add `res.ok`, error, and retry handling; never present a failed load as an empty dashboard. |
|
||||
| FIN-45 | Agree | Yes | **P3.** Add abort/error handling to settings/share fetches. |
|
||||
| FIN-46 | Agree as a credible timing race | Yes | **P2.** Use `AbortController` or a request generation tied to `classSlug`; remove or bound the module cache. |
|
||||
| FIN-47 | Agree | Yes | **P3.** Show logout failure and retry; do not falsely claim logout if the server call failed. |
|
||||
| FIN-48 | Partly agree | Yes | **P2.** Guarantee a non-empty unique slug. Keep slugs stable on rename unless redirect/history semantics are deliberately designed. |
|
||||
| FIN-49 | Agree; impact is same-browser only | Yes | **P3.** Include the share token in local session keys and migrate/ignore old keys safely. |
|
||||
| FIN-50 | Intentional newest-first convention | No separate fix | Preserve descending named-group order and final Uncategorized placement; restrict arbitrary sort-order writes under FIN-19/31. |
|
||||
| FIN-51 | Agree, cosmetic only | Not now | Defer unless it can be removed without hydration mismatch or new effect-rule violations. |
|
||||
| FIN-52 | Expected local-grading design | Warning only | **P3.** Explain in ShareMenu that recipients can inspect correct answers; do not imply answer secrecy. |
|
||||
| FIN-53 | Agree | Yes | **P1.** Replace the false-confidence assertion and add scoring/scheduling edge cases. |
|
||||
|
||||
## Remediation sequence
|
||||
|
||||
### Phase 0 — safeguards and test foundation
|
||||
|
||||
Do this before behavior or schema changes.
|
||||
|
||||
1. Create a database-backed Vitest harness that always points Prisma at a unique disposable SQLite path. It must refuse to run if the resolved path is `dev.db`, `/app/data/study.db`, or any existing non-test database.
|
||||
2. Add helpers to apply committed migrations to the disposable database and dispose of it after the test process.
|
||||
3. Record the current lint baseline separately. Do not make a failing baseline a nominally “green” CI gate, and do not mix unrelated lint cleanup into P0 fixes.
|
||||
4. Add focused pure tests first for scoring, progress normalization, import schemas, and rate limiting. These give fast feedback before route/service integration tests.
|
||||
5. Before testing any real deployment database, make and verify a restorable backup. No remediation command should run against the only copy.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Tests demonstrably create and use only a disposable DB.
|
||||
- A deliberate attempt to point the harness at the real DB aborts.
|
||||
- Existing `npm.cmd test` remains green.
|
||||
|
||||
### Phase 1 — restore a safe, reproducible deployment path
|
||||
|
||||
Addresses FIN-01, FIN-02, FIN-05–10, FIN-23–25, FIN-39, and FIN-40.
|
||||
|
||||
#### 1A. Repair migration drift without breaking already-pushed databases
|
||||
|
||||
1. Generate a new migration; never edit the four applied migrations. It must add:
|
||||
- `MaterialGroup` with its Class cascade foreign key.
|
||||
- nullable `groupId` on Deck and QuizSet with `ON DELETE SET NULL`.
|
||||
- nullable unique `groupId` on ShareLink with `ON DELETE CASCADE`.
|
||||
- the indexes Prisma expects.
|
||||
2. Review the generated SQLite table-rebuild SQL by hand for preserved rows, foreign keys, defaults, and unique indexes.
|
||||
3. Publish a one-time preflight/adoption script that classifies a database as:
|
||||
- migration-tracked and missing the group schema: apply the migration normally;
|
||||
- already schema-pushed and exactly matching the intended DDL: after backup and exact introspection, mark the new migration applied with `prisma migrate resolve --applied`;
|
||||
- partially matching or otherwise inconsistent: stop with diagnostics and require manual recovery; never guess or auto-resolve.
|
||||
4. Keep the adoption operation explicit. The normal entrypoint should not silently mutate migration history based on table existence.
|
||||
|
||||
Verification:
|
||||
|
||||
- Fresh empty DB: `prisma migrate deploy` succeeds; `prisma migrate diff --from-migrations ... --to-schema ...` is empty; Deck, QuizSet, ShareLink, and MaterialGroup queries succeed.
|
||||
- Populated pre-group DB: seed classes/decks/quizzes/shares, apply the new migration, and prove every row/count/relationship is preserved.
|
||||
- Disposable `db push`-style DB: introspect exact equivalence, resolve the migration, run deploy again, and prove an empty schema diff.
|
||||
- Partial/conflicting DB: preflight exits nonzero without changing schema or `_prisma_migrations`.
|
||||
|
||||
#### 1B. Make session configuration unforgeable and setup explicit
|
||||
|
||||
1. Centralize session option construction so `src/lib/auth.ts` and `src/proxy.ts` cannot drift.
|
||||
2. Require a nonblank, at-least-32-character production `SESSION_SECRET` at runtime. Do not require or bake the real secret during `docker build`.
|
||||
3. Make the container entrypoint fail before migrations/server startup when the secret is absent or equals the known fallback. Make Compose use required-variable expansion.
|
||||
4. Allow a development-only fallback only when `NODE_ENV !== "production"`, clearly label it non-production, and cover both branches with tests.
|
||||
5. Replace `pathname.includes(".")` with exact public/static rules. Ensure every `/api/*` path other than the intentional auth endpoints remains protected even when the URL contains a dot or encoded dot.
|
||||
6. Construct the unauthenticated redirect first and bind `getIronSession` to that response before `destroy()`, so the returned redirect carries the clearing cookie.
|
||||
7. Honor a valid `ADMIN_PASSWORD_HASH` when the database has no configured password, or require an explicit one-time setup flag/token. The default production state must not let the first remote request select the admin password.
|
||||
8. Prefer a local console/CLI initiation for password reset. If the HTTP request endpoint remains, use a global single-user throttle, never replace an unexpired token, rate-limit verify/complete, and do not trust arbitrary `x-forwarded-for` unless a trusted proxy is explicitly configured.
|
||||
|
||||
Verification:
|
||||
|
||||
- Production startup with missing, blank, short, or known fallback secret exits nonzero before serving.
|
||||
- A cookie sealed with the old fallback does not authenticate when a real secret is configured.
|
||||
- Valid login, logout, reset completion, and session-generation invalidation still work.
|
||||
- Dot-containing protected page/API requests redirect or reject; known static assets still load.
|
||||
- A stale-generation response contains a `Set-Cookie` deletion header.
|
||||
- Pre-provisioned hash rejects a different first password and reports setup complete.
|
||||
|
||||
#### 1C. Make the image and Compose definitions deterministic
|
||||
|
||||
1. Add `.dockerignore` for `node_modules`, `.next`, VCS metadata, environment files, databases/data, coverage, temporary caches, and audit scratch artifacts while retaining source, Prisma schema/migrations, lockfile, and public assets.
|
||||
2. Change the dependency stage to `npm ci`.
|
||||
3. Remove `npm install prisma@^7.8.0` from the runner. Ship a Prisma CLI/runtime installed from `package-lock.json` at the exact repository version. If Prisma is needed at runtime, classify it as a runtime dependency and prune/copy dependencies deterministically rather than re-resolving them.
|
||||
4. Map `3000:3726` in production Compose. Keep `PORT=3726` and `EXPOSE 3726` internally unless there is a deliberate decision to standardize everything on 3000.
|
||||
5. Rename `docker-compose.override.yml` to a non-auto-merged development filename such as `docker-compose.dev.yml`. Document exact production and development invocations.
|
||||
6. Add a minimal internal health route that performs schema-aware DB checks (including a query that touches `Deck.groupId` and MaterialGroup), returning no sensitive details. The container health check must call the actual internal port.
|
||||
7. Add a lock-safe SQLite backup command using the SQLite backup API, plus a documented restore drill. A raw copy of only the main DB while WAL writes are active is not an acceptable backup procedure.
|
||||
|
||||
Verification:
|
||||
|
||||
- `docker compose -f docker-compose.yml config` shows the production command, production environment, and `3000:3726` only.
|
||||
- The explicit development Compose command shows dev mode and source mounts.
|
||||
- Build on a Windows-host context and run on Linux; requiring `better-sqlite3` and `argon2` succeeds in the final image.
|
||||
- Fresh-volume container becomes healthy and `/login` returns 200 through host port 3000.
|
||||
- A migration-drifted disposable volume stays unhealthy with an actionable log.
|
||||
- Backup a populated disposable volume, destroy the disposable container/volume, restore it, and compare row counts plus representative content.
|
||||
|
||||
#### 1D. Add CI gates in a sequence that can actually be green
|
||||
|
||||
1. Gate `npm ci`, `npm test`, Prisma validate, migration/schema drift, and `npm run build` immediately.
|
||||
2. Build the production image, start it on a fresh disposable volume with a generated CI secret, wait for health, and smoke `/login` plus setup status.
|
||||
3. Tag immutable images by commit SHA; optionally move the mutable `latest` tag only after all gates pass.
|
||||
4. Add strict lint only after the existing 9-error baseline is resolved in an explicit cleanup or after a changed-files lint gate is implemented. Never report baseline lint as passing.
|
||||
|
||||
### Phase 2 — make quiz attempts authoritative and immutable
|
||||
|
||||
Addresses FIN-03, FIN-11–13, FIN-20, FIN-26, FIN-27, and FIN-53.
|
||||
|
||||
1. Replace the client-controlled `isPartialRetake` contract with an explicit ordered `questionIds` scope. The server validates that IDs are unique and belong to the quiz, then derives whether the attempt is partial.
|
||||
2. Accept a structured answers object at the route boundary and persist only canonical server-serialized JSON. Validate that:
|
||||
- each key is in the attempted scope;
|
||||
- every selected option belongs to that question;
|
||||
- selected IDs are unique;
|
||||
- multiple-choice has at most one selection;
|
||||
- missing answers score zero rather than silently disappearing.
|
||||
3. Make the in-viewer “Retake Missed” and “Retake Full Quiz” set explicit local attempt scope. Do not infer retake state from the original prop after the viewer has transitioned internally.
|
||||
4. Add an `isFinishing` state/ref, disable the Finish button during submission, and show a retryable error without discarding answers.
|
||||
5. Move attempt creation and full-attempt SEQUENTIAL-progress deletion into one service transaction. Partial retakes must not overwrite or delete a full in-progress session.
|
||||
6. Guard zero-correct SATA scoring with zero points and deduplicate selections defensively in the pure scorer even though the route also validates them.
|
||||
7. Add an optional immutable review snapshot to QuizAttempt for new attempts: attempted question/order, prompt/category/rationale, options and correctness, selections, and per-question points. Render new history from that snapshot; keep a clearly tested legacy fallback for old rows.
|
||||
8. Stabilize QuizViewer initialization. A parent render that changes topics or another surrounding control must not regenerate option order, clear answers, or reset the index. A deliberate restart/retake must use a new session key.
|
||||
|
||||
Focused verification:
|
||||
|
||||
- Full 5-question attempt scores out of 5 and clears only its matching full progress row.
|
||||
- Two-question retake scores out of 2, is stored as partial, and leaves full progress untouched.
|
||||
- An unanswered question in the explicit scope scores zero and remains in the review snapshot.
|
||||
- Duplicate/unknown question or option IDs, wrong shapes, and client-forged partial flags return 400 without creating an attempt.
|
||||
- Delayed double-click Finish produces exactly one attempt.
|
||||
- Changing or deleting current quiz content after an attempt does not change the stored score, category breakdown, or review copy for snapshot-backed attempts.
|
||||
- Parent rerender preserves question order, option order, current index, and answers; explicit restart changes only the intended session.
|
||||
|
||||
### Phase 3 — make progress and flashcard resume race-safe
|
||||
|
||||
Addresses FIN-04, FIN-14, FIN-15, FIN-21, FIN-34, and FIN-36.
|
||||
|
||||
1. Replace or correct `filterAndClampOrder` with a normalization function that accepts saved order, live IDs, saved index, results/answers, and completion state. It must:
|
||||
- remove stale and duplicate IDs;
|
||||
- preserve the saved current ID when it still exists;
|
||||
- when it was deleted, select the next surviving saved card, otherwise the previous survivor;
|
||||
- preserve `index === oldOrder.length` as completion by returning `index === newOrder.length`;
|
||||
- filter result/answer keys to live IDs;
|
||||
- safely fall back when any JSON field is malformed.
|
||||
2. Apply normalization before any viewer state initializer calls `JSON.parse`. Treat corrupt legacy progress as recoverable, show a small restore warning, and allow a fresh start.
|
||||
3. Add Zod schemas for GET/PATCH/DELETE progress inputs, enum values, nonnegative index, content-specific JSON shape, and referenced content existence.
|
||||
4. Add `sessionId` and monotonic `revision` to progress. The server accepts only a newer revision for the same session; a stale session cannot overwrite or delete a newer session. Serialize client saves but retain server-side revision enforcement because request completion order is not guaranteed.
|
||||
5. On the last flashcard, persist `currentIndex = order.length`. Restoring that row must show the existing completion summary, not the last card.
|
||||
6. Await restart deletion before remount/navigation. Make DELETE conditional on the session being cleared so a late old request cannot delete fresh progress.
|
||||
7. Disable Previous, shuffle, and repeated grading during the 350 ms transition, and clear/cancel the timer on restart/unmount.
|
||||
|
||||
Focused verification:
|
||||
|
||||
- Stale ID before, at, and after the current index all resume on the correct logical card.
|
||||
- All saved cards deleted yields a clear empty/completed state rather than a blank viewer.
|
||||
- Completed sessions reopen on the summary; restart opens card 1.
|
||||
- Invalid JSON and invalid PATCH payloads produce a safe UI fallback or 400, never a render crash/500.
|
||||
- Deliver revisions 3, 1, and 2 in that order; the DB retains revision 3.
|
||||
- Delay an old-session DELETE until after a new-session PATCH; the new session remains.
|
||||
- Grade then immediately try Previous/restart; no index jump or stale result write occurs.
|
||||
|
||||
### Phase 4 — enforce group, ordering, and sharing invariants on the server
|
||||
|
||||
Addresses FIN-18, FIN-19, FIN-32, FIN-33, FIN-41, FIN-49, FIN-50, and FIN-52.
|
||||
|
||||
1. Move reorder logic into focused services. The route must identify the owning class and content type from the database, not trust the client.
|
||||
2. Validate every target group exists, belongs to the same class, and matches DECK/QUIZ type. Reject duplicate IDs, foreign-class IDs, type mismatches, and unknown IDs.
|
||||
3. Prefer an ordered list/group assignment contract and compute contiguous `sortOrder` values server-side. If a complete-set contract is required, verify completeness explicitly.
|
||||
4. In the library, capture origin group before any update, update state immutably, await `res.ok`, disable conflicting mutations in flight, and rollback/refetch on failure.
|
||||
5. Delete a group in one transaction: capture affected items, delete/reassign through the FK behavior, then renumber Uncategorized items deterministically. Add `createdAt`/`id` tie-breakers to reads so legacy duplicates are stable.
|
||||
6. Add a keyboard-accessible Move-to-group menu on every item. Preserve drag-and-drop for pointer users and keep drag listeners off action controls.
|
||||
7. Validate ShareLink target type and target existence. Enforce exactly one populated target in service logic; consider CHECK constraints in a later SQLite migration after compatibility testing.
|
||||
8. Retain the shared-page class/type/token checks and additionally assert each selected group item has the group’s class and expected content type.
|
||||
9. Pass the share token/session namespace to shared viewers and include it in localStorage keys. Avoid copying an old item-only session into a different token namespace without explicit user confirmation.
|
||||
10. Preserve named-group ordering as `sortOrder desc, createdAt desc`, prepend newly created groups locally, and render Uncategorized last. Remove arbitrary `sortOrder` from the ordinary group rename PATCH contract.
|
||||
11. Add a concise ShareMenu warning that a shared quiz necessarily sends answer/rationale data to the recipient’s browser for local grading.
|
||||
|
||||
Focused verification:
|
||||
|
||||
- Cross-class and cross-type group assignment returns 400/409 and changes no rows.
|
||||
- A failing reorder restores/refetches the visible order.
|
||||
- Deleting a group yields unique contiguous Uncategorized item order and does not delete decks/quizzes.
|
||||
- Newer named groups stay above older groups; Uncategorized remains last in quizzes, flashcards, and import selectors.
|
||||
- A keyboard-only user can move an item between two groups and hear/see confirmation.
|
||||
- Invalid share target types and missing IDs create zero rows.
|
||||
- A tampered group share cannot render a foreign-class item.
|
||||
- Two tokens for the same content maintain independent local sessions.
|
||||
|
||||
### Phase 5 — validation, SRS integrity, and user-visible error handling
|
||||
|
||||
Addresses FIN-16, FIN-28–31, FIN-35, FIN-37, FIN-43–48, and the measurable part of FIN-17.
|
||||
|
||||
1. Reuse shared Zod schemas for card create/edit, deck/quiz create/edit, classes, imports, and material groups. Trim before minimum checks and add documented maximums for names, descriptions, Markdown content, options, questions, and cards.
|
||||
2. Reject a Create-tab submission containing any partially filled card; identify the row(s) instead of silently dropping them.
|
||||
3. Enforce at least two correct options for imported SATA questions while leaving existing stored questions readable. Add a targeted message explaining how to repair invalid generated JSON.
|
||||
4. Narrow Prisma error mapping: P2025 becomes 404, P2002 becomes 409 where appropriate, validation is 400/422, and unexpected failures remain 500 with non-sensitive server diagnostics.
|
||||
5. In SRS review, verify the submitted card is the queue-eligible card for the current set/time/new-card allowance (including deliberate learn-ahead). Retain the existing state-version comparison. Map concurrent first-review P2002 to 409 and return/refetch current study state.
|
||||
6. Refresh SRS membership on focus and after same-tab deck changes.
|
||||
7. Add abortable, checked fetch helpers or a small consistent pattern for Dashboard, GenerateTab, ShareMenu, logout, and class library requests. Distinguish loading, empty, error, and retry states.
|
||||
8. Tie library responses to the active `classSlug`; abort or ignore stale responses. Bound or remove module-level caches that can outlive their class data.
|
||||
9. Guarantee class slug generation produces a non-empty unique slug, for example a stable `class-<short-id>` fallback. Keep existing slugs stable on rename.
|
||||
10. Add `data/`, `study.db`, and sidecars to `.gitignore`. Confirm `out.css`, `temp.css`, and `test.css` are unreferenced scratch artifacts before removing them in a separate cleanup change.
|
||||
11. For performance, first seed a disposable benchmark DB approximating expected upper use (for example 100k activity rows, 10k cards, and several SRS sets). Add date predicates/aggregation and narrower SRS queries only where the measured request/poll budget is exceeded. Preserve long-streak correctness when limiting the activity window.
|
||||
|
||||
Focused verification:
|
||||
|
||||
- Whitespace-only and oversized content is rejected with an actionable client error and no partial write.
|
||||
- SATA 1-correct import fails; 2+-correct passes; multiple-choice still requires exactly one.
|
||||
- SRS cannot review a not-due/non-selected/new-limit-exhausted card; valid due and learn-ahead reviews still work.
|
||||
- Concurrent first review produces one success and one handled conflict, not a generic 500.
|
||||
- Focus refresh removes a deleted deck from the SRS set UI.
|
||||
- Failed dashboard/settings/share/logout requests have visible, retryable states and no unhandled promise rejection.
|
||||
- Rapid A→B class navigation cannot render A data under B.
|
||||
- Creating a class named only punctuation yields a navigable, unique slug; rename does not break the old URL.
|
||||
- Performance tests record query counts and latency before/after and prove the 53-week display plus longer current streak remain correct.
|
||||
|
||||
## Cross-cutting release verification
|
||||
|
||||
Run after every phase, with focused tests first and the full suite last.
|
||||
|
||||
Automated gates:
|
||||
|
||||
1. `npm.cmd test`
|
||||
2. `npx.cmd prisma validate`
|
||||
3. migration deploy + empty migration/schema diff against a fresh disposable DB whenever schema changes
|
||||
4. `npm.cmd run build`
|
||||
5. `git diff --check`
|
||||
6. production image build, fresh-volume health wait, and HTTP smoke when Docker/deployment files change
|
||||
7. lint against changed files; full `npm.cmd run lint` only becomes blocking after the known baseline is cleared
|
||||
|
||||
Manual browser matrix for affected phases:
|
||||
|
||||
1. Authenticated routes and corresponding public/shared routes.
|
||||
2. Fresh, resumed, completed, restarted, partial-retake, and stale/deleted-content sessions.
|
||||
3. Loading, empty, server-error, malformed-persisted-data, and retry states.
|
||||
4. Light and dark themes.
|
||||
5. Narrow mobile viewport with no horizontal overflow.
|
||||
6. Keyboard-only navigation, including group moves and all modified icon controls.
|
||||
7. Throttled-network checks for Finish, autosave, restart, class navigation, reorder, and logout.
|
||||
8. Two-tab checks for progress revisions, SRS conflicts, and focus refresh.
|
||||
|
||||
Release/rollback rules:
|
||||
|
||||
- Back up and restore-test the SQLite database before the first migration-bearing release.
|
||||
- Deploy schema and code as one versioned release; do not run a newer generated Prisma client against an older database.
|
||||
- Keep the prior image and verified pre-migration backup until post-deploy smoke and representative data checks pass.
|
||||
- If migration preflight sees a partial or unknown schema state, stop. Do not run `db push`, edit an applied migration, reset the database, or auto-mark the migration applied.
|
||||
|
||||
## Explicitly deferred or rejected work
|
||||
|
||||
- **FIN-38:** no timezone-setting work without a product requirement to support a non-Arizona study day.
|
||||
- **FIN-42:** no history rewrite without an offline sensitivity review and explicit remote/clone coordination.
|
||||
- **FIN-51:** no cosmetic state-initialization change unless it avoids both hydration mismatch and the existing effect-rule class of lint failures.
|
||||
- **FIN-22 retry proposal:** no automatic migration retry/backoff for schema conflicts; fail fast with health diagnostics and the adoption/recovery runbook.
|
||||
- No changes to the audit’s already rejected hypotheses (global API auth absence, split-brain SQLite paths, broken cascades/transactions, Markdown XSS, or client/server import violations) unless new evidence appears.
|
||||
|
||||
## Recommended delivery slices
|
||||
|
||||
Keep the implementation reviewable rather than landing all remediation at once:
|
||||
|
||||
1. **Release blocker:** FIN-01/02/05/06/07/23/25 plus migration adoption, container smoke, and backup prerequisites.
|
||||
2. **Quiz correctness:** FIN-03/11/12/13/20/26/27/53.
|
||||
3. **Progress integrity:** FIN-04/14/15/21/34/36.
|
||||
4. **Group/share integrity:** FIN-18/19/32/33/41/49/50/52.
|
||||
5. **Auth recovery and deployment posture:** FIN-08/09/10/24/39/40.
|
||||
6. **Validation, SRS, UI resilience, and measured performance:** the remaining accepted items.
|
||||
|
||||
Each slice should be independently releasable, have its own focused regression tests, and finish with the cross-cutting gates above.
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
# Subagent Reports
|
||||
|
||||
Full specialist reports were produced by 10 read-only subagents. Each returned a structured markdown report with a findings table and per-finding detail blocks (ID, severity, confidence, files:lines, scenario, expected/actual, root cause, impact, evidence, test coverage, fix direction, complexity, regression test, runtime-confirmation flag). The parent agent re-verified the Critical/High claims directly (see VERIFICATION_LOG.md) and consolidated/deduplicated into `FINDINGS.json` (53 findings).
|
||||
|
||||
## Report index (all reports were reviewed in full by the parent)
|
||||
|
||||
| # | Auditor | Ref | Key output |
|
||||
|---|---|---|---|
|
||||
| 1 | DB / migrations / transactions / cascades / drift | sa_20260806_054030_000000000_5ed877563a0d | **DBAUD-01 Critical migration drift** (MaterialGroup/groupId never migrated; commit 7af0935 db-push artifact); DBAUD-02 junk ShareLink rows; DBAUD-03 dev.db in git history; verified-safe: cascades, transactions, path resolution, single-row AuthSecurity |
|
||||
| 2 | Quiz scoring / attempts / retakes / progress | sa_20260806_054030_000000000_e75a8ce575f9 | **QUIZ-01 High retake-scoring bug** (in-viewer retake never sets retakeIds); QUIZ-02..09 (unvalidated answersJson, double-submit, progress races, dead filter, div-by-zero, historical recompute, multi-tab) |
|
||||
| 3 | Flashcards / SRS / progress / deletion / ordering | sa_20260806_054030_000000000_a222804c133c | CARD-01..11: dead stale-id filter (stuck sessions), unsequenced saves, completed-session resume, SRS due/limit not enforced, unbounded scans, Arizona day boundary, restart race, P2002, animation race, focus staleness |
|
||||
| 4 | Auth / protected routes / API authz / sharing | sa_20260806_054030_000000000_aab0c59dbad3 | **AUTH-01 Critical hardcoded session secret**; AUTH-02 dot-bypass; AUTH-03 reset abuse; AUTH-04 first-login takeover + dead ADMIN_PASSWORD_HASH; AUTH-05..10 (cookie-clear loss, cross-class group membership, share validation, secure flag, no auth tests, answer-key exposure); verified proxy compiled as middleware |
|
||||
| 5 | Imports / exports / generation / Zod / malformed input | sa_20260806_054030_000000000_5e72c2713989 | IMPT-01..06: dead filterAndClampOrder, unvalidated /api/progress + unguarded JSON.parse, card edit endpoints without Zod, no size caps, SATA doc mismatch, override-field 500s; verified: shared schemas, atomic imports, no XSS, no id regeneration |
|
||||
| 6 | Material groups / ordering / drag-drop / deletion / orphans | sa_20260806_054030_000000000_9ae1f3dfa282 | **GRP-01 Critical drift** (independent confirmation); GRP-02..07: unvalidated groupId/sortOrder, fire-and-forget reorder, sortOrder duplicates after group deletion, keyboard a11y, desc-convention fragility; verified: FKs enforced, uncategorized mapping, cascade safety |
|
||||
| 7 | Frontend state / refresh / races / localStorage / boundaries | sa_20260806_054030_000000000_0e17fd155702 | FE-01..11: optimistic reorder without rollback, CRUD res.ok ignored, shared quiz reshuffle (unstable prop), finish double-submit, progress race, **FE-06 (later refuted — proxy exists)**, dashboard fetch gap, unguarded JSON.parse, effect fetches, cross-class race, collapse flash; verified: shared viewers read-only, localStorage clean, no boundary violations |
|
||||
| 8 | Test quality / coverage / false confidence | sa_20260806_054030_000000000_cd3652deb3f7 | TEST-01..10: false-confidence SRS test, zero tests for scoring/auth/services/routes, no DB harness, CI never runs tests; ranked missing-test gaps by impact |
|
||||
| 9 | Docker / startup / env / scripts / production / recovery | sa_20260806_054030_000000000_f49cdaacaabf | OPS-01..10: port mismatch 3726 vs 3000:3000, entrypoint fragility, session secret in prod path, dead ADMIN_PASSWORD_HASH, non-reproducible installs, no .dockerignore (win32 leak), no healthcheck/backup, override auto-merge, .gitignore gaps, CI without gates; argon2/standalone tracing verified OK |
|
||||
| 10 | Adversarial review | sa_20260806_055608_000000000_c550c742d5dc | Challenged all Critical/High findings: confirmed drift, secret, retake, port, dockerignore, override; weakened OPS-02 (fresh-volume migrate succeeds) and FE-01 severity; **refuted FE-06** (proxy active — verified in compiled .next artifacts); added ADV-01..10 (retake progress pollution, activity scan, logout, dashboard crash, slugify empty slug, unguarded parses, completed-resume, localStorage key collisions, first-login race, progress validation) |
|
||||
|
||||
All reports are preserved in full in the session; their findings are consolidated (deduplicated, source IDs preserved) in `FINDINGS.json` and `CONFIRMED_FINDINGS.md`.
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
# Test Gaps
|
||||
|
||||
Current suite: 6 files / 30 tests — 20 in `src/lib/arcade/*` (out of scope) + 10 in `src/lib/spacedRepetition.test.ts` (pure scheduling math only). **No test touches a service, an API route, the database, auth, scoring, imports, or progress.** CI never runs `npm test`. Gaps ranked by user-impact × regression-likelihood.
|
||||
|
||||
| ID | Area | Gap | Risk it would fail to catch | Suggested test |
|
||||
|---|---|---|---|---|
|
||||
| TEST-GAP-01 | Auth (FIN-02/08/09/10) | `authService.ts` (login, lockout, first-login provisioning, reset token, generation bump) and session config have zero tests | Session-secret fallback regression, lockout bypass, generation-bump loss (stolen sessions stay valid) | Extract + unit-test `getLockoutDuration` thresholds, `parseResetTokenRecord`; service tests with temp SQLite + fake timers: 5 wrong passwords → 423; locked-out login rejected; success resets counter; `completePasswordReset` bumps generation |
|
||||
| TEST-GAP-02 | Scoring (FIN-03/11/26/27) | `src/lib/scoring.ts` SATA partial credit has zero tests | Wrong grades silently persisted into every attempt — permanent data corruption | `scoreQuestion`: SATA 2/4 correct select both → 1; 1 correct + 1 wrong → 0; over-select → 0; MC multi-select first-wrong → 0; `scoreQuiz` with missing question keys; 0-correct SATA → 0 (not NaN) |
|
||||
| TEST-GAP-03 | Quiz attempt persistence (FIN-03/12/13) | `POST /api/quizzes/[id]/attempt` + partial-retake filter + `progressService.upsertProgress` asymmetry (DECK writes cardResultsJson, QUIZ writes answersJson) have zero tests | Retake-scoring regression (FIN-03 class), duplicate attempts, progress column clobbering | Route/service test: partial retake answering 2 of 5 → maxScore 2, score from those 2; upsert round-trip preserves the respective JSON columns |
|
||||
| TEST-GAP-04 | SRS service (FIN-16/35) | `spacedRepetitionService.ts` (reviewCard optimistic concurrency, findNextCard precedence, removeDeck transaction, addDeck validations) has zero tests — only the pure lib is tested | Double-review stale write, due/new-card precedence break, cross-class membership, P2002 → 500 | Seed class→deck→cards→set on temp DB: stale `expectedStateVersion` → 409; due before new before learn-ahead; new card with limit 0 rejected; removeDeck atomically deletes states |
|
||||
| TEST-GAP-05 | Import/repair pipeline (FIN-28/29/30) | `jsonRepair.ts`, `importSchemas.ts`, `shuffle.ts`/`filterAndClampOrder` have zero tests | AI-import regression (fences/repair), schema constraint drift (MC exactly-one), resume clamp bugs | jsonRepair fence/trailing-comma/broken-JSON cases; schema whitespace/oversize/exactly-one cases; `filterAndClampOrder` stale-id + overflow cases |
|
||||
| TEST-GAP-06 | Rate limiter (FIN-10) | `rateLimiter.ts` has zero tests | Brute-force guard silently disabled or locking the owner out | Fake timers: 10 allowed / 11th blocked with retryAfterMs; window expiry; per-key isolation; custom limits |
|
||||
| TEST-GAP-07 | Integration harness (FIN-01/21/25) | No route tests, no DB-backed tests, no setup file; `src/lib/db.ts` defaults to `file:./dev.db` (a naive service test would touch the real dev DB) | Migration drift (FIN-01) — nothing would catch a schema/migration mismatch; progress/share/attempt route validation gaps | `setupFiles` + `DATABASE_URL=file:<tmp>` + `prisma migrate deploy` in globalSetup; route tests with `NextRequest`: attempt 200/400/404, reviews 409, share unknown token, login 429 |
|
||||
| TEST-GAP-08 | CI gate (FIN-25) | `.forgejo/workflows/build.yml` runs no tests/lint/prisma validate; no coverage config; no drift check | Entire suite can regress with a green pipeline; FIN-01-class drift ships | Add `npm ci && npm test && npm run lint && npx prisma validate` job; `prisma migrate diff --from-migrations --to-schema-datamodel` gate; container smoke (curl /login on fresh volume); coverage floor |
|
||||
| TEST-GAP-09 | False confidence (FIN-53) | `spacedRepetition.test.ts:15-23` "distinct valid state for every rating" never compares ratings | `scheduleRating` collapsing all ratings to one schedule passes the suite | Assert pairwise due ordering AGAIN < HARD < GOOD < EASY and distinct due timestamps; graduated-state lapse case |
|
||||
|
||||
## Test-quality positives (verified)
|
||||
|
||||
- `spacedRepetition.test.ts` is deterministic (fixed `now`, `enable_fuzz: false`, no `Math.random`), tests the real pure module, and asserts exact FSRS intervals.
|
||||
- Arcade tests (out of scope) use seeded shuffles and meaningful assertions.
|
||||
- No test uses wall-clock time, locale, or network — no flakiness found.
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
# Unverified Risks
|
||||
|
||||
Risks that could not be fully confirmed within audit constraints (need a browser, a container run, a live deployment, or timing-dependent reproduction). All are code-verified as plausible; the missing piece is runtime confirmation.
|
||||
|
||||
| ID | Title | Why unverified | Suggested verification |
|
||||
|---|---|---|---|
|
||||
| FIN-02 | Hardcoded fallback `SESSION_SECRET` → session forgery | Code path fully traced (seal/verify semantics verified in iron-session), but no live production deployment exists here to demonstrate an actual forged-cookie login | Boot production build without `SESSION_SECRET`, forge a cookie with the fallback password (iron-webcrypto seal script), assert full access; then with a real secret assert rejection |
|
||||
| FIN-06 | No `.dockerignore` → win32 modules in Linux image | Mechanics verified (PE32+ DLL present in local node_modules; Dockerfile COPY order), exact failure mode (build error vs `ERR_DLOPEN_FAILED`) not observed | Run `docker build .` on this Windows host; inspect `better_sqlite3.node`/argon2 prebuilds in the image (must be ELF) |
|
||||
| FIN-13 | Progress PATCH-after-DELETE race | Timing-dependent; needs throttled network | DevTools throttling: submit final answer + Finish immediately; inspect `StudyProgress` after |
|
||||
| FIN-14 | Out-of-order progress PATCHes | Timing-dependent | DevTools throttling with rapid grading; reload and compare resume point/results |
|
||||
| FIN-22 | Entrypoint migrate crash loop / runner CLI install | Requires container build + run on `node:22-slim` (docker CLI unavailable in audit environment) | `docker build` + fresh-volume run (assert 200 + `_prisma_migrations`); corrupt-volume run (assert actionable failure, not silent loop) |
|
||||
| FIN-24 | Healthcheck/backup gaps | Behavior observable only in a real container run | Compose up with a corrupt volume; observe restart loop and absence of health status |
|
||||
| FIN-32 | Group-delete sortOrder duplicates | Deterministic from code; visual impact needs a browser | Delete a group in the app, inspect Uncategorized order + DB sortOrders |
|
||||
| FIN-33 | Keyboard cross-group moves impossible | Deterministic from code (empty `handleDragOver`), needs manual keyboard test | Tab to a drag handle, attempt keyboard cross-group move |
|
||||
| FIN-34 | Restart DELETE race | Timing-dependent | Throttled network: Restart, grade first card, inspect progress row |
|
||||
| FIN-36 | Previous-card animation race | Timing-dependent (350 ms window) | Grade then immediately click Previous; observe index jump |
|
||||
| FIN-37 | SRS page stale on focus | Deterministic from code; browser needed to observe | Two tabs: delete deck in one, focus the other |
|
||||
| FIN-39 | Proxy destroy cookie lost on redirect | Deterministic from code; browser needed to observe Set-Cookie | Stale-generation cookie → follow redirect → inspect response headers |
|
||||
| FIN-40 | Secure cookie flag off | Deterministic from code (flag logic); deployment-dependent | Inspect `Set-Cookie` in a production container |
|
||||
| FIN-46 | Cross-class fetch race | Timing-dependent | Throttled network + fast class switching |
|
||||
| FIN-48 | Empty slug / stale slug on rename | Deterministic from code (slugify verified); UI behavior needs browser | Create class named "!!!", try to navigate to it; rename a class, check old URL |
|
||||
| FIN-49 | Shared-viewer localStorage key collisions | Code-verified key construction; symptom needs browser | Open two share tokens for the same content, answer in one, reload the other |
|
||||
| FIN-51 | Collapsed-groups expand flash | Cosmetic; browser-only | Visit library with saved collapsed state |
|
||||
|
||||
## Items verified safe (hypotheses disproven — recorded for completeness)
|
||||
|
||||
- SQLite relative-path split-brain between Prisma CLI and app (both cwd-relative → same `dev.db`; Docker uses absolute path) — verified safe.
|
||||
- Foreign-key enforcement (better-sqlite3 compiled with `SQLITE_DEFAULT_FOREIGN_KEYS=1`) — cascades/SetNull fire; verified safe.
|
||||
- Transactionality of deck/quiz/attempt/reorder writes — nested creates + `$transaction`; verified safe.
|
||||
- `StudyProgress`/`ShareLink` NULL-uniqueness — service-level guards prevent duplicates via app paths; only direct API misuse (FIN-41/FIN-21) can create junk rows.
|
||||
- Client/server boundary violations — none found (no client import of `@/lib/db`/services/prisma).
|
||||
- XSS via Markdown — react-markdown without `rehype-raw`; no `dangerouslySetInnerHTML` on user data; verified safe.
|
||||
- Password-reset nonce/token crypto (192-bit token, SHA-256 digest, `timingSafeEqual`, expiry) — sound; abuse vectors are FIN-10.
|
||||
- SRS optimistic concurrency (`expectedStateVersion` → 409, rollback via refetch) — correct.
|
||||
- Migrations are purely additive; no NOT NULL-without-default, no DROP — safe on populated migration-tracked DBs (except FIN-01 drift).
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
# Verification Log
|
||||
|
||||
Chronological record of every command run, its outcome, and classification (baseline / environment / possible defect).
|
||||
|
||||
## Environment notes
|
||||
|
||||
- OS: Windows, shell: bash (git-bash)
|
||||
- `node_modules` was **absent** at audit start → `npm test` initially failed with `'vitest' is not recognized`. This is an environment condition, not a repo defect. `npm ci` completed successfully during the first audit session (518 packages present).
|
||||
- No existing application database (`dev.db*`) exists anywhere in the repo — no real user data to protect; the migration-chain test uses a brand-new temp DB under `audit-results/tmp`.
|
||||
- `src/generated/prisma/` (tracked) hashed BEFORE any prisma command: `audit-results/tmp/generated-before.sha256`.
|
||||
|
||||
## Entries
|
||||
|
||||
| # | Time | Command | Result | Classification |
|
||||
|---|---|---|---|---|
|
||||
| 1 | session start | `git status --short` | clean; only `?? .reasonix/` and `?? audit-results/` untracked | baseline |
|
||||
| 2 | session start | `npm test` (first run, prior session) | FAILED: `'vitest' is not recognized` (no node_modules) | environment |
|
||||
| 3 | session start | `npm ci` (background, prior session) | completed; 518 packages in node_modules | environment |
|
||||
| 4 | 22:36 | `npx prisma validate` | PASS — "The schema at prisma\schema.prisma is valid" (exit 0) | baseline |
|
||||
| 5 | 22:36 | `npm test` | PASS — 6 files, 30 tests, all green (arcade ×20, spacedRepetition ×10) | baseline |
|
||||
| 6 | 22:37 | `npm run lint` | 28 problems: 9 errors, 19 warnings (react-hooks/set-state-in-effect errors + unused-vars warnings). All pre-existing; matches AGENTS.md known baseline. | baseline |
|
||||
| 7 | 22:37 | `npm run build` (background) | PASS — route table emitted, no compile errors | baseline |
|
||||
| 8 | 22:38 | `DATABASE_URL=file:./audit-results/tmp/audit-migration.db npx prisma migrate deploy` | PASS — all 4 migrations applied to brand-new temp DB | baseline |
|
||||
| 9 | 22:38 | `sha256sum -c audit-results/tmp/generated-before.sha256` | PASS — every generated Prisma file byte-identical (all ": OK") | baseline |
|
||||
| 10 | 22:38 | `git status --short` | only untracked `.reasonix/`, `audit-results/`; no tracked modifications | baseline |
|
||||
| 11 | 22:38 | `git diff --check` | clean (no whitespace errors) | baseline |
|
||||
| 12 | 22:40 | `git log --all --oneline -- prisma/migrations/` + `git show 7af0935 --stat` | Confirms drift: commit 7af0935 changed `prisma/schema.prisma` (+31) and `dev.db` (139264→155648 bytes) with **no migration**; `dev.db` present in 8 commits of history | possible defect (FIN-01, FIN-42) |
|
||||
| 13 | 22:41 | `node audit-results/tmp/inspect-db.cjs audit-results/tmp/audit-migration.db` | Temp DB (built solely by `migrate deploy`) has **no** `MaterialGroup` table and **no** `groupId` column on Deck/QuizSet/ShareLink | possible defect (FIN-01) |
|
||||
| 14 | 22:42 | `node audit-results/tmp/reproduce-drift.cjs` | Prisma-shaped queries fail: `no such column: Deck.groupId`, `no such table: MaterialGroup`, `no such column: ShareLink.groupId` — fresh-deploy breakage proven | possible defect (FIN-01) |
|
||||
| 15 | 22:45 | `node --check audit-results/tmp/inspect-db.cjs && node --check audit-results/tmp/reproduce-drift.cjs` | diagnostic scripts syntax-valid | baseline |
|
||||
| 16 | 22:50 | `node audit-results/tmp/validate-findings.cjs` | `FINDINGS.json` VALID JSON — 53 findings: 2 Critical, 5 High, 18 Medium, 25 Low, 3 Observation; no duplicate ids | baseline |
|
||||
| 17 | 23:00 | `git status --short` + `git diff --check` (final) | Only untracked `.reasonix/`, `audit-results/`; no tracked file modified; diff clean | baseline |
|
||||
|
||||
## Classification legend
|
||||
|
||||
- **baseline** — repository behaves as expected; recorded for reference.
|
||||
- **environment** — failure caused by local machine state, not repo code.
|
||||
- **possible defect** — outcome may indicate a repo defect; investigated further in findings.
|
||||
Binary file not shown.
|
|
@ -1,26 +0,0 @@
|
|||
cb737289f5b5f6cdb8b90fc23d2c89ebd41d6602ce99e88bf6dfd751beab7103 *src/generated/prisma/browser.ts
|
||||
2ea331124cc56c9c9a360b4a85f42f029992811ece7efe9db0ec1300a43f59c3 *src/generated/prisma/client.ts
|
||||
f1f6280ff65c1e8aab5ce30307c8777ce1795f81bf1a513396263fc1c9a30e5c *src/generated/prisma/commonInputTypes.ts
|
||||
8bcc37ae19ee9c55424b735ce5f3a0972b7e741958ef86b350c9a54dc2238a0d *src/generated/prisma/enums.ts
|
||||
d52628a3dc8285d9014e2fda489badce6c88604810e2975310ab6c40ec3c708d *src/generated/prisma/internal/class.ts
|
||||
91794eb9b3c395e57b96d18f2bae895149f7b70ddf0fda8b817e7aa9da17a950 *src/generated/prisma/internal/prismaNamespace.ts
|
||||
6645109792ed08507d79da319aaecc2752bffa6e8bd5a3364dccc5ae93e2f23c *src/generated/prisma/internal/prismaNamespaceBrowser.ts
|
||||
b8ffef1ad4428179847aafe66abee3ad5eb74a84fff46265532551b10ab2537d *src/generated/prisma/models.ts
|
||||
daecbb1c94a96d19da4accfdf81fe8f11fa73c543958016bc7d6060d454f3943 *src/generated/prisma/models/AnswerOption.ts
|
||||
87e18727cec17a341d32709bc0c1602c66e6c6dcc57672b9c9fbe38a6ce12801 *src/generated/prisma/models/ArcadeAttempt.ts
|
||||
0c9de62bdbefafe8b5ff5300d1d764cc73235a077f8e3b3e03e6144287610c9d *src/generated/prisma/models/ArcadePack.ts
|
||||
67b8ea01f0a866d14db7fdff3178a080e2879990129c5e08690463fb9940f48d *src/generated/prisma/models/AuthSecurity.ts
|
||||
6885465a57019e8cba28755d9c6abaa087e913d666d02d8912abf0790c41a77b *src/generated/prisma/models/Class.ts
|
||||
ea873177511c6f7354dc01ec0ad228d9f777bca2fbfb15d281d3f2ab8f817ebf *src/generated/prisma/models/Deck.ts
|
||||
1082ff29bec6e4f920c93d778032ffffa324c312b05efd6c5fa7ada1be6674f7 *src/generated/prisma/models/Flashcard.ts
|
||||
de94c2da405c08a0b690aeb1b0cdc03582c94ccddaf913f4c6d1b18d8c413b61 *src/generated/prisma/models/MaterialGroup.ts
|
||||
941998d2c098e9fe5cd0a888bac470190108238c936c1d7cfce2742ed1e40d8a *src/generated/prisma/models/Question.ts
|
||||
3d54ed93e516f4d773c200b166a84ab2c9c8b7b0d7f7ddfd61afc3ca057ee755 *src/generated/prisma/models/QuizAttempt.ts
|
||||
454f19f97a82b8f37fc5c5179e87afe6ea185a79810043471d55c0a8ca26f14a *src/generated/prisma/models/QuizSet.ts
|
||||
3b884aaaad311e8f22c1390cf24ebe69b74b52a97c213781d2a4cbb56288b8a7 *src/generated/prisma/models/Setting.ts
|
||||
1ccf32b2f56b1d35c9b7152d2e1817b4c1b7853549a1ff187e9fb7c29fa49f2a *src/generated/prisma/models/ShareLink.ts
|
||||
cba950d431c702afe67f3ac92d8397275d3aeeebbc59d357fd20dec2123e0ee2 *src/generated/prisma/models/SpacedRepetitionCardState.ts
|
||||
2ef43d1c5d7e2eb0d85080fb29703ae99c1704ba8f5200703e2bb8e7a617bf04 *src/generated/prisma/models/SpacedRepetitionSet.ts
|
||||
0bf9eab9f937ef2d621ccf0cea8e209eae9372a9b333dfe6b18267e563a6b2b0 *src/generated/prisma/models/SpacedRepetitionSetDeck.ts
|
||||
916bda8f95647f0dc3560bc6848c4a97d4c1f37fd5ed54e6736d9d7d45e08adc *src/generated/prisma/models/StudyActivity.ts
|
||||
36724157e76b49df49e7827eacbcb0ab8336917f35913777701d8a6830770500 *src/generated/prisma/models/StudyProgress.ts
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
// Read-only diagnostic: introspect the temp DB created by `prisma migrate deploy`
|
||||
// to check whether the migrated schema matches schema.prisma (drift check).
|
||||
// Usage: node audit-results/tmp/inspect-db.cjs <path-to-sqlite-db>
|
||||
const path = require("node:path");
|
||||
const Database = require("better-sqlite3");
|
||||
|
||||
const dbPath = process.argv[2];
|
||||
if (!dbPath) {
|
||||
console.error("usage: node inspect-db.cjs <db-path>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const db = new Database(path.resolve(dbPath), { readonly: true });
|
||||
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
.all()
|
||||
.map((r) => r.name);
|
||||
|
||||
console.log("=== TABLES ===");
|
||||
console.log(tables.join(", "));
|
||||
console.log("Has MaterialGroup table:", tables.includes("MaterialGroup"));
|
||||
|
||||
for (const table of ["Deck", "QuizSet", "ShareLink", "MaterialGroup"]) {
|
||||
if (tables.includes(table)) {
|
||||
const cols = db
|
||||
.prepare(`PRAGMA table_info('${table}')`)
|
||||
.all()
|
||||
.map((r) => r.name);
|
||||
console.log(`--- ${table} columns ---`);
|
||||
console.log(cols.join(", "));
|
||||
if (table === "Deck" || table === "QuizSet" || table === "ShareLink") {
|
||||
console.log(` -> has groupId:`, cols.includes("groupId"));
|
||||
}
|
||||
} else {
|
||||
console.log(`--- ${table}: MISSING TABLE ---`);
|
||||
}
|
||||
}
|
||||
|
||||
const indexes = db
|
||||
.prepare("SELECT name, tbl_name FROM sqlite_master WHERE type='index' ORDER BY name")
|
||||
.all()
|
||||
.map((r) => `${r.tbl_name}.${r.name}`);
|
||||
console.log("=== INDEXES (count) ===", indexes.length);
|
||||
|
||||
// Confirm migrations table
|
||||
const applied = db.prepare("SELECT migration_name FROM _prisma_migrations ORDER BY started_at").all();
|
||||
console.log("=== APPLIED MIGRATIONS ===");
|
||||
applied.forEach((m) => console.log(" -", m.migration_name));
|
||||
|
||||
db.close();
|
||||
console.log("DONE");
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
// Read-only diagnostic: execute the exact SQL shape the generated Prisma client
|
||||
// emits for deck.findMany() / materialGroup.findMany() against a DB built purely
|
||||
// from `prisma migrate deploy` (predev + Docker entrypoint flow).
|
||||
// Proves DBAUD-01: migrated DB is incompatible with the shipped client.
|
||||
const path = require("node:path");
|
||||
const Database = require("better-sqlite3");
|
||||
|
||||
const dbPath = path.resolve("audit-results/tmp/audit-migration.db");
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
|
||||
// 1. Shape of prisma.deck.findMany({ include: { class: true } }) — Prisma selects
|
||||
// every scalar field, including groupId (schema.prisma:33-34, generated client).
|
||||
const deckSql =
|
||||
'SELECT "Deck"."id", "Deck"."classId", "Deck"."name", "Deck"."description", "Deck"."sortOrder", "Deck"."createdAt", "Deck"."groupId" FROM "Deck"';
|
||||
try {
|
||||
db.prepare(deckSql).all();
|
||||
console.log("deck.findMany SQL: OK");
|
||||
} catch (e) {
|
||||
console.log("deck.findMany SQL: FAILED ->", e.message);
|
||||
}
|
||||
|
||||
// 2. Shape of prisma.materialGroup.findMany()
|
||||
const groupSql = 'SELECT "MaterialGroup"."id", "MaterialGroup"."classId", "MaterialGroup"."name", "MaterialGroup"."type", "MaterialGroup"."sortOrder", "MaterialGroup"."createdAt" FROM "MaterialGroup"';
|
||||
try {
|
||||
db.prepare(groupSql).all();
|
||||
console.log("materialGroup.findMany SQL: OK");
|
||||
} catch (e) {
|
||||
console.log("materialGroup.findMany SQL: FAILED ->", e.message);
|
||||
}
|
||||
|
||||
// 3. Shape of prisma.shareLink.findFirst() (share page validation)
|
||||
const shareSql = 'SELECT "ShareLink"."id", "ShareLink"."targetType", "ShareLink"."deckId", "ShareLink"."quizSetId", "ShareLink"."groupId", "ShareLink"."createdAt" FROM "ShareLink" LIMIT 1';
|
||||
try {
|
||||
db.prepare(shareSql).all();
|
||||
console.log("shareLink.findFirst SQL: OK");
|
||||
} catch (e) {
|
||||
console.log("shareLink.findFirst SQL: FAILED ->", e.message);
|
||||
}
|
||||
|
||||
db.close();
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
// Validates audit-results/FINDINGS.json parses and reports counts per severity.
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const file = path.resolve("audit-results/FINDINGS.json");
|
||||
const raw = fs.readFileSync(file, "utf8");
|
||||
const data = JSON.parse(raw); // throws if invalid
|
||||
|
||||
const findings = data.findings;
|
||||
const bySeverity = {};
|
||||
for (const f of findings) {
|
||||
bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1;
|
||||
}
|
||||
const byConfidence = {};
|
||||
for (const f of findings) {
|
||||
byConfidence[f.confidence] = (byConfidence[f.confidence] || 0) + 1;
|
||||
}
|
||||
|
||||
const ids = findings.map((f) => f.id);
|
||||
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
|
||||
if (dupes.length) throw new Error("duplicate ids: " + dupes.join(","));
|
||||
|
||||
console.log("FINDINGS.json: VALID JSON");
|
||||
console.log("total findings:", findings.length);
|
||||
console.log("by severity:", JSON.stringify(bySeverity));
|
||||
console.log("by confidence:", JSON.stringify(byConfidence));
|
||||
console.log("ids:", ids.join(", "));
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
import { performance } from "node:perf_hooks";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getActivitySummary } from "@/services/activityService";
|
||||
import { listClasses } from "@/services/classService";
|
||||
import { getStudyAvailabilityByClass } from "@/services/spacedRepetitionService";
|
||||
|
||||
const ACTIVITY_ROWS = 100_000;
|
||||
const CARD_ROWS = 10_000;
|
||||
const SETS = 5;
|
||||
const REQUEST_BUDGET_MS = 250;
|
||||
const NOW = new Date("2026-08-07T18:00:00.000Z");
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
function databasePath() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url?.startsWith("file:./")) {
|
||||
throw new Error("Benchmark requires the disposable Vitest database");
|
||||
}
|
||||
return path.resolve(process.cwd(), url.slice("file:".length));
|
||||
}
|
||||
|
||||
function seedRealApplicationSchema() {
|
||||
const database = new Database(databasePath());
|
||||
try {
|
||||
database.pragma("foreign_keys = ON");
|
||||
const insertActivity = database.prepare(
|
||||
'INSERT INTO "StudyActivity" ("id", "type", "occurredAt") VALUES (?, ?, ?)'
|
||||
);
|
||||
const insertDeck = database.prepare(
|
||||
'INSERT INTO "Deck" ("id", "classId", "name", "sortOrder") VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
const insertSet = database.prepare(
|
||||
'INSERT INTO "SpacedRepetitionSet" ("id", "classId", "name", "newCardsPerDay", "sortOrder", "updatedAt") VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
const insertMembership = database.prepare(
|
||||
'INSERT INTO "SpacedRepetitionSetDeck" ("setId", "deckId", "sortOrder") VALUES (?, ?, ?)'
|
||||
);
|
||||
const insertCard = database.prepare(
|
||||
'INSERT INTO "Flashcard" ("id", "deckId", "front", "back", "sortOrder") VALUES (?, ?, ?, ?, ?)'
|
||||
);
|
||||
const insertState = database.prepare(`
|
||||
INSERT INTO "SpacedRepetitionCardState"
|
||||
("id", "setId", "flashcardId", "due", "stability", "difficulty", "elapsedDays", "scheduledDays", "learningSteps", "reps", "lapses", "state", "firstReviewedAt", "lastReview", "createdAt", "updatedAt")
|
||||
VALUES (?, ?, ?, ?, 1, 5, 1, ?, 0, 1, 0, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
database.transaction(() => {
|
||||
database.prepare(
|
||||
'INSERT INTO "Class" ("id", "slug", "name", "sortOrder") VALUES (?, ?, ?, 0)'
|
||||
).run("benchmark-class", "benchmark-class", "Benchmark Class");
|
||||
|
||||
for (let index = 0; index < ACTIVITY_ROWS; index += 1) {
|
||||
const ageDays = index % 730;
|
||||
const type = index % 2 === 0 ? "FLASHCARD" : "QUIZ_QUESTION";
|
||||
insertActivity.run(
|
||||
`activity-${index}`,
|
||||
type,
|
||||
NOW.getTime() - ageDays * 86_400_000
|
||||
);
|
||||
}
|
||||
|
||||
for (let setIndex = 0; setIndex < SETS; setIndex += 1) {
|
||||
const deckId = `benchmark-deck-${setIndex}`;
|
||||
const setId = `benchmark-set-${setIndex}`;
|
||||
insertDeck.run(deckId, "benchmark-class", `Deck ${setIndex}`, setIndex);
|
||||
insertSet.run(setId, "benchmark-class", `Set ${setIndex}`, 30, setIndex, NOW.getTime());
|
||||
insertMembership.run(setId, deckId, 0);
|
||||
for (let cardIndex = setIndex; cardIndex < CARD_ROWS; cardIndex += SETS) {
|
||||
const cardId = `benchmark-card-${cardIndex}`;
|
||||
const due = NOW.getTime() + ((cardIndex % 200) - 100) * 60_000;
|
||||
const firstReviewed = NOW.getTime() - (cardIndex % 30) * 86_400_000;
|
||||
const scheduledDays = cardIndex % 4 === 0 ? 1 : 0;
|
||||
const state = cardIndex % 4;
|
||||
insertCard.run(cardId, deckId, `Front ${cardIndex}`, `Back ${cardIndex}`, cardIndex);
|
||||
insertState.run(
|
||||
`benchmark-state-${cardIndex}`,
|
||||
setId,
|
||||
cardId,
|
||||
due,
|
||||
scheduledDays,
|
||||
state,
|
||||
firstReviewed,
|
||||
firstReviewed,
|
||||
firstReviewed,
|
||||
NOW.getTime()
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function measure<T>(label: string, operation: () => Promise<T>) {
|
||||
const start = performance.now();
|
||||
const value = await operation();
|
||||
return {
|
||||
label,
|
||||
latencyMs: Number((performance.now() - start).toFixed(2)),
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
describe("remediation performance condition", () => {
|
||||
it("measures actual migrated services at the expected upper-use fixture", async () => {
|
||||
seedRealApplicationSchema();
|
||||
const activity = await measure("getActivitySummary", () => getActivitySummary(NOW));
|
||||
const availability = await measure("getStudyAvailabilityByClass", () =>
|
||||
getStudyAvailabilityByClass(NOW)
|
||||
);
|
||||
const classPolling = await measure("listClasses/navbar-poll", () => listClasses());
|
||||
const measurements = [activity, availability, classPolling].map((result) => ({
|
||||
label: result.label,
|
||||
latencyMs: result.latencyMs,
|
||||
}));
|
||||
|
||||
console.info(JSON.stringify({
|
||||
fixture: { activityRows: ACTIVITY_ROWS, cards: CARD_ROWS, sets: SETS },
|
||||
budgetMs: REQUEST_BUDGET_MS,
|
||||
prismaServiceOperations: { activity: 1, availability: 1, classPolling: 2 },
|
||||
measurements,
|
||||
activity: {
|
||||
displayedDays: activity.value.days.length,
|
||||
currentStreak: activity.value.currentStreak,
|
||||
},
|
||||
readyCards: availability.value["benchmark-class"]?.readyCards,
|
||||
}, null, 2));
|
||||
|
||||
expect(activity.value.days).toHaveLength(53 * 7);
|
||||
expect(activity.value.currentStreak).toBeGreaterThan(53 * 7);
|
||||
expect(availability.value["benchmark-class"]).toBeDefined();
|
||||
expect(classPolling.value).toHaveLength(1);
|
||||
for (const measurement of measurements) {
|
||||
expect(measurement.latencyMs, measurement.label).toBeLessThanOrEqual(
|
||||
REQUEST_BUDGET_MS
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,15 +1,12 @@
|
|||
services:
|
||||
study-app:
|
||||
build:
|
||||
context: .
|
||||
target: deps
|
||||
command: npm run dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- DATABASE_URL=file:/app/data/study.db
|
||||
- NODE_ENV=development
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
|
@ -4,13 +4,11 @@ services:
|
|||
container_name: study-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3726"
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- DATABASE_URL=file:/app/data/study.db
|
||||
- SESSION_SECRET=${SESSION_SECRET:?SESSION_SECRET must be set to at least 32 characters}
|
||||
- ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH:-}
|
||||
- ALLOW_INITIAL_SETUP=${ALLOW_INITIAL_SETUP:-false}
|
||||
- SECURE_COOKIES=${SECURE_COOKIES:-false}
|
||||
- SESSION_SECRET=${SESSION_SECRET}
|
||||
- ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH}
|
||||
- NODE_ENV=production
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
|
|
|
|||
|
|
@ -1,13 +1,4 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
DEFAULT_SECRET='dev-session-secret-change-in-production-must-be-32-chars'
|
||||
TRIMMED_SECRET=$(printf '%s' "${SESSION_SECRET:-}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
if [ -z "$TRIMMED_SECRET" ] || [ "${#TRIMMED_SECRET}" -lt 32 ] || [ "$TRIMMED_SECRET" = "$DEFAULT_SECRET" ]; then
|
||||
echo >&2 "SESSION_SECRET must be a non-default value of at least 32 characters"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
node scripts/migration-preflight.mjs --startup
|
||||
./node_modules/.bin/prisma migrate deploy
|
||||
npx prisma migrate deploy
|
||||
exec node server.js
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ const eslintConfig = defineConfig([
|
|||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
"audit-results/tmp/**",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
|
|
|||
1
out.css
Normal file
1
out.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
:root { --color-primary: #fff; }
|
||||
621
package-lock.json
generated
621
package-lock.json
generated
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
|
@ -8,11 +8,7 @@
|
|||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"benchmark:remediation": "vitest run --config vitest.benchmark.config.ts",
|
||||
"db:preflight": "node scripts/migration-preflight.mjs",
|
||||
"db:backup": "node scripts/backup-database.mjs",
|
||||
"auth:reset": "node scripts/request-password-reset.mjs"
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
@ -24,8 +20,7 @@
|
|||
"better-sqlite3": "^12.11.1",
|
||||
"iron-session": "^8.0.4",
|
||||
"jsonrepair": "^3.14.1",
|
||||
"next": "16.3.0",
|
||||
"prisma": "^7.8.0",
|
||||
"next": "16.2.9",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
|
@ -35,12 +30,12 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/better-sqlite3": "^9.6.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.9",
|
||||
"prisma": "^7.8.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.10"
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MaterialGroup" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "MaterialGroup_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
CREATE TABLE "new_Deck" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"groupId" TEXT,
|
||||
CONSTRAINT "Deck_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "Deck_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MaterialGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Deck" ("classId", "createdAt", "description", "id", "name", "sortOrder")
|
||||
SELECT "classId", "createdAt", "description", "id", "name", "sortOrder" FROM "Deck";
|
||||
DROP TABLE "Deck";
|
||||
ALTER TABLE "new_Deck" RENAME TO "Deck";
|
||||
|
||||
CREATE TABLE "new_QuizSet" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"groupId" TEXT,
|
||||
CONSTRAINT "QuizSet_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "QuizSet_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MaterialGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_QuizSet" ("classId", "createdAt", "description", "id", "name", "sortOrder")
|
||||
SELECT "classId", "createdAt", "description", "id", "name", "sortOrder" FROM "QuizSet";
|
||||
DROP TABLE "QuizSet";
|
||||
ALTER TABLE "new_QuizSet" RENAME TO "QuizSet";
|
||||
|
||||
CREATE TABLE "new_ShareLink" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"targetType" TEXT NOT NULL,
|
||||
"deckId" TEXT,
|
||||
"quizSetId" TEXT,
|
||||
"groupId" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "ShareLink_deckId_fkey" FOREIGN KEY ("deckId") REFERENCES "Deck" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "ShareLink_quizSetId_fkey" FOREIGN KEY ("quizSetId") REFERENCES "QuizSet" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "ShareLink_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MaterialGroup" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_ShareLink" ("createdAt", "deckId", "id", "quizSetId", "targetType")
|
||||
SELECT "createdAt", "deckId", "id", "quizSetId", "targetType" FROM "ShareLink";
|
||||
DROP TABLE "ShareLink";
|
||||
ALTER TABLE "new_ShareLink" RENAME TO "ShareLink";
|
||||
CREATE UNIQUE INDEX "ShareLink_deckId_key" ON "ShareLink"("deckId");
|
||||
CREATE UNIQUE INDEX "ShareLink_quizSetId_key" ON "ShareLink"("quizSetId");
|
||||
CREATE UNIQUE INDEX "ShareLink_groupId_key" ON "ShareLink"("groupId");
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "QuizAttempt" ADD COLUMN "reviewSnapshotJson" TEXT;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "StudyProgress" ADD COLUMN "sessionId" TEXT NOT NULL DEFAULT 'legacy';
|
||||
ALTER TABLE "StudyProgress" ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 0;
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
-- Remove Arcade-generated activity and saved instruction settings.
|
||||
DELETE FROM "StudyActivity"
|
||||
WHERE "type" IN ('ARCADE_GROUP', 'ARCADE_WORD');
|
||||
|
||||
DELETE FROM "Setting"
|
||||
WHERE "key" IN ('llmInstructionsConnections', 'llmInstructionsCrossword');
|
||||
|
||||
-- Drop the dependent table before its parent while SQLite foreign keys are enabled.
|
||||
DROP TABLE IF EXISTS "ArcadeAttempt";
|
||||
DROP TABLE IF EXISTS "ArcadePack";
|
||||
|
|
@ -18,6 +18,7 @@ model Class {
|
|||
quizSets QuizSet[]
|
||||
materialGroups MaterialGroup[]
|
||||
spacedRepetitionSets SpacedRepetitionSet[]
|
||||
arcadePacks ArcadePack[]
|
||||
}
|
||||
|
||||
model Deck {
|
||||
|
|
@ -98,8 +99,6 @@ model StudyProgress {
|
|||
orderJson String // JSON array of card/question ids
|
||||
answersJson String? // in-progress quiz answers
|
||||
cardResultsJson String? // per-card grades for decks
|
||||
sessionId String @default("legacy")
|
||||
revision Int @default(0)
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
deck Deck? @relation(fields: [deckId], references: [id], onDelete: Cascade)
|
||||
|
|
@ -110,14 +109,13 @@ model StudyProgress {
|
|||
}
|
||||
|
||||
model QuizAttempt {
|
||||
id String @id @default(uuid())
|
||||
quizSetId String
|
||||
score Float
|
||||
maxScore Int
|
||||
answersJson String
|
||||
reviewSnapshotJson String?
|
||||
isPartialRetake Boolean @default(false)
|
||||
completedAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
quizSetId String
|
||||
score Float
|
||||
maxScore Int
|
||||
answersJson String
|
||||
isPartialRetake Boolean @default(false)
|
||||
completedAt DateTime @default(now())
|
||||
|
||||
quizSet QuizSet @relation(fields: [quizSetId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
|
@ -149,12 +147,52 @@ model Setting {
|
|||
|
||||
model StudyActivity {
|
||||
id String @id @default(uuid())
|
||||
type String // "FLASHCARD" | "QUIZ_QUESTION"
|
||||
type String // "FLASHCARD" | "QUIZ_QUESTION" | "ARCADE_GROUP"
|
||||
occurredAt DateTime @default(now())
|
||||
|
||||
@@index([occurredAt])
|
||||
}
|
||||
|
||||
model ArcadePack {
|
||||
id String @id @default(uuid())
|
||||
classId String
|
||||
gameType String
|
||||
name String
|
||||
description String?
|
||||
schemaVersion Int
|
||||
sourceJson String
|
||||
normalizedJson String
|
||||
validationReportJson String
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
attempts ArcadeAttempt[]
|
||||
|
||||
@@index([classId, gameType, sortOrder])
|
||||
}
|
||||
|
||||
model ArcadeAttempt {
|
||||
id String @id @default(uuid())
|
||||
arcadePackId String
|
||||
mode String
|
||||
score Int
|
||||
maxScore Int
|
||||
accuracy Float
|
||||
durationSeconds Int
|
||||
mistakes Int
|
||||
hintsUsed Int
|
||||
settingsJson String
|
||||
resultsJson String
|
||||
seed String
|
||||
completedAt DateTime @default(now())
|
||||
|
||||
arcadePack ArcadePack @relation(fields: [arcadePackId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([arcadePackId, completedAt])
|
||||
}
|
||||
|
||||
model MaterialGroup {
|
||||
id String @id @default(uuid())
|
||||
classId String
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import Database from "better-sqlite3";
|
||||
import { databasePathFromUrl } from "./databasePath.mjs";
|
||||
|
||||
function argument(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
export async function backupDatabase(sourcePath, outputPath) {
|
||||
const source = path.resolve(sourcePath);
|
||||
const output = path.resolve(outputPath);
|
||||
if (!existsSync(source)) throw new Error(`Source database does not exist: ${source}`);
|
||||
if (existsSync(output)) throw new Error(`Backup destination already exists: ${output}`);
|
||||
if (source === output) throw new Error("Backup destination must differ from source");
|
||||
|
||||
const database = new Database(source, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
await database.backup(output);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
const backup = new Database(output, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const integrity = backup.pragma("integrity_check", { simple: true });
|
||||
if (integrity !== "ok") throw new Error(`Backup integrity check failed: ${integrity}`);
|
||||
} finally {
|
||||
backup.close();
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const source = argument("--source") ?? databasePathFromUrl(process.env.DATABASE_URL);
|
||||
const output = argument("--output");
|
||||
if (!output) throw new Error("Usage: npm run db:backup -- --output <new-backup.db>");
|
||||
const completedPath = await backupDatabase(source, output);
|
||||
console.info(`Verified SQLite backup created: ${completedPath}`);
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import path from "node:path";
|
||||
|
||||
export function databasePathFromUrl(databaseUrl, cwd = process.cwd()) {
|
||||
if (!databaseUrl?.startsWith("file:")) {
|
||||
throw new Error("DATABASE_URL must be a file: SQLite URL");
|
||||
}
|
||||
const rawPath = databaseUrl.slice("file:".length).split("?")[0];
|
||||
if (!rawPath) throw new Error("DATABASE_URL does not contain a database path");
|
||||
return path.resolve(cwd, rawPath);
|
||||
}
|
||||
|
|
@ -1,358 +0,0 @@
|
|||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import Database from "better-sqlite3";
|
||||
import { databasePathFromUrl } from "./databasePath.mjs";
|
||||
|
||||
export const GROUP_MIGRATION = "20260807090000_add_material_groups";
|
||||
|
||||
function tableExists(database, table) {
|
||||
return Boolean(
|
||||
database
|
||||
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(table)
|
||||
);
|
||||
}
|
||||
|
||||
function hasColumn(database, table, column) {
|
||||
if (!tableExists(database, table)) return false;
|
||||
return database.pragma(`table_info(${table})`).some((item) => item.name === column);
|
||||
}
|
||||
|
||||
function hasForeignKey(database, table, from, target, onDelete) {
|
||||
return database
|
||||
.pragma(`foreign_key_list(${table})`)
|
||||
.some(
|
||||
(item) =>
|
||||
item.from === from && item.table === target && item.on_delete === onDelete
|
||||
);
|
||||
}
|
||||
|
||||
function hasUniqueIndex(database, table, indexName, column) {
|
||||
const index = database
|
||||
.pragma(`index_list(${table})`)
|
||||
.find((item) => item.name === indexName && item.unique === 1);
|
||||
if (!index) return false;
|
||||
const columns = database.pragma(`index_info(${indexName})`);
|
||||
return columns.length === 1 && columns[0].name === column;
|
||||
}
|
||||
|
||||
function schemaSnapshot(database, includeMigrationTable = false) {
|
||||
const tables = database
|
||||
.prepare(
|
||||
`SELECT name FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
${includeMigrationTable ? "" : "AND name <> '_prisma_migrations'"}
|
||||
ORDER BY name`
|
||||
)
|
||||
.all()
|
||||
.map((row) => row.name);
|
||||
|
||||
return tables.map((table) => ({
|
||||
table,
|
||||
columns: database.pragma(`table_info(${JSON.stringify(table)})`).map((column) => ({
|
||||
name: column.name,
|
||||
type: column.type,
|
||||
notnull: column.notnull,
|
||||
defaultValue: column.dflt_value,
|
||||
primaryKey: column.pk,
|
||||
})),
|
||||
foreignKeys: database
|
||||
.pragma(`foreign_key_list(${JSON.stringify(table)})`)
|
||||
.map((foreignKey) => ({
|
||||
id: foreignKey.id,
|
||||
sequence: foreignKey.seq,
|
||||
target: foreignKey.table,
|
||||
from: foreignKey.from,
|
||||
to: foreignKey.to,
|
||||
onUpdate: foreignKey.on_update,
|
||||
onDelete: foreignKey.on_delete,
|
||||
match: foreignKey.match,
|
||||
}))
|
||||
.sort((left, right) => left.id - right.id || left.sequence - right.sequence),
|
||||
indexes: database
|
||||
.pragma(`index_list(${JSON.stringify(table)})`)
|
||||
.filter((index) => !index.name.startsWith("sqlite_autoindex_"))
|
||||
.map((index) => ({
|
||||
name: index.name,
|
||||
unique: index.unique,
|
||||
partial: index.partial,
|
||||
columns: database
|
||||
.pragma(`index_info(${JSON.stringify(index.name)})`)
|
||||
.map((column) => column.name),
|
||||
}))
|
||||
.sort((left, right) => left.name.localeCompare(right.name)),
|
||||
}));
|
||||
}
|
||||
|
||||
function expectedSchemaSnapshot(migrationNames) {
|
||||
const database = new Database(":memory:");
|
||||
try {
|
||||
database.pragma("foreign_keys = ON");
|
||||
for (const migrationName of migrationNames) {
|
||||
database.exec(
|
||||
readFileSync(
|
||||
path.join(
|
||||
process.cwd(),
|
||||
"prisma",
|
||||
"migrations",
|
||||
migrationName,
|
||||
"migration.sql"
|
||||
),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
}
|
||||
return schemaSnapshot(database);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function inspectMigrationState(databasePath) {
|
||||
if (!existsSync(databasePath)) {
|
||||
throw new Error(`Database does not exist: ${databasePath}`);
|
||||
}
|
||||
const database = new Database(databasePath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const migrationRecords = tableExists(database, "_prisma_migrations")
|
||||
? database
|
||||
.prepare(
|
||||
'SELECT "migration_name", "finished_at", "rolled_back_at" FROM "_prisma_migrations"'
|
||||
)
|
||||
.all()
|
||||
: [];
|
||||
const activeRecords = migrationRecords.filter((row) => row.rolled_back_at === null);
|
||||
const migrationNames = activeRecords
|
||||
.filter((row) => row.finished_at !== null)
|
||||
.map((row) => row.migration_name);
|
||||
const failedMigrationNames = activeRecords
|
||||
.filter((row) => row.finished_at === null)
|
||||
.map((row) => row.migration_name);
|
||||
const committedMigrations = readdirSync(
|
||||
path.join(process.cwd(), "prisma", "migrations"),
|
||||
{ withFileTypes: true }
|
||||
)
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
const priorMigrations = committedMigrations.filter(
|
||||
(name) => name < GROUP_MIGRATION
|
||||
);
|
||||
const groupMigrationIndex = committedMigrations.indexOf(GROUP_MIGRATION);
|
||||
|
||||
const artifacts = {
|
||||
materialGroup: tableExists(database, "MaterialGroup"),
|
||||
deckColumn: hasColumn(database, "Deck", "groupId"),
|
||||
quizColumn: hasColumn(database, "QuizSet", "groupId"),
|
||||
shareColumn: hasColumn(database, "ShareLink", "groupId"),
|
||||
deckForeignKey: hasForeignKey(
|
||||
database,
|
||||
"Deck",
|
||||
"groupId",
|
||||
"MaterialGroup",
|
||||
"SET NULL"
|
||||
),
|
||||
quizForeignKey: hasForeignKey(
|
||||
database,
|
||||
"QuizSet",
|
||||
"groupId",
|
||||
"MaterialGroup",
|
||||
"SET NULL"
|
||||
),
|
||||
shareForeignKey: hasForeignKey(
|
||||
database,
|
||||
"ShareLink",
|
||||
"groupId",
|
||||
"MaterialGroup",
|
||||
"CASCADE"
|
||||
),
|
||||
shareUnique: hasUniqueIndex(
|
||||
database,
|
||||
"ShareLink",
|
||||
"ShareLink_groupId_key",
|
||||
"groupId"
|
||||
),
|
||||
};
|
||||
const priorHistoryComplete = priorMigrations.every((name) =>
|
||||
migrationNames.includes(name)
|
||||
);
|
||||
const knownMigrationNames = new Set(committedMigrations);
|
||||
const historyHasUnknown = activeRecords.some(
|
||||
(row) => !knownMigrationNames.has(row.migration_name)
|
||||
);
|
||||
const actualSchema = schemaSnapshot(database);
|
||||
const matches = (expected) =>
|
||||
JSON.stringify(actualSchema) === JSON.stringify(expected);
|
||||
const preGroupSchemaExact = matches(expectedSchemaSnapshot(priorMigrations));
|
||||
const adoptedGroupSchemaExact = matches(
|
||||
expectedSchemaSnapshot([...priorMigrations, GROUP_MIGRATION])
|
||||
);
|
||||
let historyPrefixLength = 0;
|
||||
while (
|
||||
historyPrefixLength < committedMigrations.length &&
|
||||
migrationNames.includes(committedMigrations[historyPrefixLength])
|
||||
) {
|
||||
historyPrefixLength += 1;
|
||||
}
|
||||
const historyIsExactPrefix =
|
||||
!historyHasUnknown &&
|
||||
migrationNames.length === historyPrefixLength &&
|
||||
migrationNames.every((name) =>
|
||||
committedMigrations.slice(0, historyPrefixLength).includes(name)
|
||||
);
|
||||
const trackedSchemaExact =
|
||||
historyIsExactPrefix &&
|
||||
matches(expectedSchemaSnapshot(committedMigrations.slice(0, historyPrefixLength)));
|
||||
let schemaPrefixLength = -1;
|
||||
for (let index = 0; index <= committedMigrations.length; index += 1) {
|
||||
if (matches(expectedSchemaSnapshot(committedMigrations.slice(0, index)))) {
|
||||
schemaPrefixLength = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const migrationsToAdopt =
|
||||
schemaPrefixLength > historyPrefixLength
|
||||
? committedMigrations.slice(historyPrefixLength, schemaPrefixLength)
|
||||
: [];
|
||||
const failuresAreAdoptable = failedMigrationNames.every((name) =>
|
||||
migrationsToAdopt.includes(name)
|
||||
);
|
||||
|
||||
let classification = "CONFLICT";
|
||||
if (
|
||||
historyIsExactPrefix &&
|
||||
failedMigrationNames.length === 0 &&
|
||||
schemaPrefixLength === historyPrefixLength
|
||||
) {
|
||||
classification =
|
||||
historyPrefixLength === committedMigrations.length ? "CURRENT" : "APPLY";
|
||||
} else if (
|
||||
historyIsExactPrefix &&
|
||||
priorHistoryComplete &&
|
||||
historyPrefixLength >= groupMigrationIndex &&
|
||||
schemaPrefixLength > historyPrefixLength &&
|
||||
failuresAreAdoptable
|
||||
) {
|
||||
classification = "ADOPT";
|
||||
}
|
||||
|
||||
return {
|
||||
classification,
|
||||
artifacts,
|
||||
migrationNames,
|
||||
failedMigrationNames,
|
||||
migrationsToAdopt,
|
||||
priorHistoryComplete,
|
||||
schemaMigrationPrefix:
|
||||
schemaPrefixLength >= 0
|
||||
? committedMigrations[schemaPrefixLength - 1] ?? null
|
||||
: null,
|
||||
exactSchema: {
|
||||
preGroup: preGroupSchemaExact,
|
||||
adoptedGroup: adoptedGroupSchemaExact,
|
||||
tracked: trackedSchemaExact,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
function verifyBackup(backupPath, sourcePath) {
|
||||
if (!backupPath || !existsSync(backupPath)) {
|
||||
throw new Error("--resolve requires an existing verified --backup file");
|
||||
}
|
||||
if (path.resolve(backupPath) === path.resolve(sourcePath)) {
|
||||
throw new Error("--backup must be a separate database file, not the source database");
|
||||
}
|
||||
const backup = new Database(backupPath, { readonly: true, fileMustExist: true });
|
||||
const source = new Database(sourcePath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
if (backup.pragma("integrity_check", { simple: true }) !== "ok") {
|
||||
throw new Error("Backup failed SQLite integrity_check");
|
||||
}
|
||||
if (
|
||||
JSON.stringify(schemaSnapshot(backup, true)) !==
|
||||
JSON.stringify(schemaSnapshot(source, true))
|
||||
) {
|
||||
throw new Error("Backup schema does not match the source database");
|
||||
}
|
||||
const sourceCounts = Object.fromEntries(
|
||||
schemaSnapshot(source, true).map(({ table }) => [
|
||||
table,
|
||||
source.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count,
|
||||
])
|
||||
);
|
||||
const backupCounts = Object.fromEntries(
|
||||
schemaSnapshot(backup, true).map(({ table }) => [
|
||||
table,
|
||||
backup.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count,
|
||||
])
|
||||
);
|
||||
if (JSON.stringify(sourceCounts) !== JSON.stringify(backupCounts)) {
|
||||
throw new Error("Backup row counts do not match the source database");
|
||||
}
|
||||
} finally {
|
||||
backup.close();
|
||||
source.close();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMigration(databaseUrl, migrationName) {
|
||||
const cli = path.join(process.cwd(), "node_modules", "prisma", "build", "index.js");
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[cli, "migrate", "resolve", "--applied", migrationName],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, DATABASE_URL: databaseUrl },
|
||||
encoding: "utf8",
|
||||
}
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Prisma migration resolve failed:\n${result.stdout}\n${result.stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const databasePath = databasePathFromUrl(databaseUrl);
|
||||
const startup = process.argv.includes("--startup");
|
||||
const state =
|
||||
startup && !existsSync(databasePath)
|
||||
? {
|
||||
classification: "EMPTY",
|
||||
migrationNames: [],
|
||||
failedMigrationNames: [],
|
||||
migrationsToAdopt: [],
|
||||
}
|
||||
: inspectMigrationState(databasePath);
|
||||
console.info(JSON.stringify(state, null, 2));
|
||||
|
||||
if (process.argv.includes("--resolve")) {
|
||||
if (state.classification !== "ADOPT") {
|
||||
throw new Error(`Refusing adoption for ${state.classification} database state`);
|
||||
}
|
||||
const backupIndex = process.argv.indexOf("--backup");
|
||||
verifyBackup(
|
||||
backupIndex >= 0 ? process.argv[backupIndex + 1] : undefined,
|
||||
databasePath
|
||||
);
|
||||
for (const migrationName of state.migrationsToAdopt) {
|
||||
resolveMigration(databaseUrl, migrationName);
|
||||
}
|
||||
console.info(
|
||||
`Marked ${state.migrationsToAdopt.join(", ")} applied after exact-schema preflight.`
|
||||
);
|
||||
} else if (startup && state.classification === "ADOPT") {
|
||||
console.error(
|
||||
"Database schema requires explicit migration adoption. Stop, create and verify a backup, then run db:preflight -- --resolve --backup <backup-path>."
|
||||
);
|
||||
process.exitCode = 3;
|
||||
} else if (state.classification === "CONFLICT") {
|
||||
process.exitCode = 2;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import Database from "better-sqlite3";
|
||||
import { databasePathFromUrl } from "./databasePath.mjs";
|
||||
|
||||
const PASSWORD_HASH_KEY = "admin_password_hash";
|
||||
const RESET_TOKEN_KEY = "admin_password_reset";
|
||||
const RESET_TOKEN_LIFETIME_MS = 15 * 60 * 1000;
|
||||
|
||||
function activeRecord(value) {
|
||||
try {
|
||||
const record = JSON.parse(value);
|
||||
return new Date(record.expiresAt).getTime() > Date.now();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createResetToken(databasePath) {
|
||||
if (!existsSync(databasePath)) throw new Error(`Database does not exist: ${databasePath}`);
|
||||
const database = new Database(databasePath, { fileMustExist: true });
|
||||
try {
|
||||
if (!database.prepare('SELECT 1 FROM "Setting" WHERE "key" = ?').get(PASSWORD_HASH_KEY)) {
|
||||
throw new Error("Password recovery is unavailable before initial setup");
|
||||
}
|
||||
const existing = database
|
||||
.prepare('SELECT "value" FROM "Setting" WHERE "key" = ?')
|
||||
.get(RESET_TOKEN_KEY);
|
||||
if (existing && activeRecord(existing.value)) {
|
||||
throw new Error("An unexpired reset token already exists; it was not replaced");
|
||||
}
|
||||
|
||||
const token = randomBytes(24).toString("base64url");
|
||||
const expiresAt = new Date(Date.now() + RESET_TOKEN_LIFETIME_MS).toISOString();
|
||||
const value = JSON.stringify({
|
||||
digest: createHash("sha256").update(token, "utf8").digest("hex"),
|
||||
nonce: randomUUID(),
|
||||
expiresAt,
|
||||
});
|
||||
database
|
||||
.prepare(
|
||||
'INSERT INTO "Setting" ("key", "value") VALUES (?, ?) ON CONFLICT("key") DO UPDATE SET "value" = excluded."value"'
|
||||
)
|
||||
.run(RESET_TOKEN_KEY, value);
|
||||
return { token, expiresAt };
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const result = createResetToken(databasePathFromUrl(process.env.DATABASE_URL));
|
||||
console.info(`Password reset token: ${result.token}`);
|
||||
console.info(`Expires: ${result.expiresAt}`);
|
||||
}
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import { backupDatabase } from "./backup-database.mjs";
|
||||
|
||||
const root = path.join(process.cwd(), ".test-databases");
|
||||
const directory = path.join(root, `backup-restore-${randomUUID()}`);
|
||||
if (!path.resolve(directory).startsWith(`${path.resolve(root)}${path.sep}`)) {
|
||||
throw new Error("Unsafe disposable backup verification path");
|
||||
}
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const sourcePath = path.join(directory, "source.test.db");
|
||||
const backupPath = path.join(directory, "backup.test.db");
|
||||
const restoredPath = path.join(directory, "restored.test.db");
|
||||
let source;
|
||||
|
||||
function rowCounts(database) {
|
||||
const tables = database
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||
)
|
||||
.all()
|
||||
.map((row) => row.name);
|
||||
return Object.fromEntries(
|
||||
tables.map((table) => [
|
||||
table,
|
||||
database.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
source = new Database(sourcePath);
|
||||
source.pragma("foreign_keys = ON");
|
||||
source.pragma("journal_mode = WAL");
|
||||
source.pragma("wal_autocheckpoint = 0");
|
||||
const migrations = readdirSync(path.join(process.cwd(), "prisma", "migrations"), {
|
||||
withFileTypes: true,
|
||||
})
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
for (const migration of migrations) {
|
||||
source.exec(
|
||||
readFileSync(
|
||||
path.join(process.cwd(), "prisma", "migrations", migration, "migration.sql"),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
}
|
||||
source.transaction(() => {
|
||||
source
|
||||
.prepare('INSERT INTO "Class" ("id", "name", "slug", "sortOrder") VALUES (?, ?, ?, ?)')
|
||||
.run("backup-class", "Representative content", "representative-content", 0);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "MaterialGroup" ("id", "classId", "name", "type", "sortOrder") VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run("backup-group", "backup-class", "Backup group", "DECK", 0);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "Deck" ("id", "classId", "groupId", "name", "description", "sortOrder") VALUES (?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run(
|
||||
"backup-deck",
|
||||
"backup-class",
|
||||
"backup-group",
|
||||
"Backup deck",
|
||||
"Restore drill content",
|
||||
0
|
||||
);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "Flashcard" ("id", "deckId", "front", "back", "sortOrder") VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run("backup-card", "backup-deck", "Representative front", "Representative back", 0);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "QuizSet" ("id", "classId", "name", "description", "sortOrder") VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run("backup-quiz", "backup-class", "Backup quiz", "Quiz relationship", 0);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "Question" ("id", "quizSetId", "type", "prompt", "rationale", "category", "sortOrder") VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run(
|
||||
"backup-question",
|
||||
"backup-quiz",
|
||||
"MULTIPLE_CHOICE",
|
||||
"Representative prompt",
|
||||
"Representative rationale",
|
||||
"backup",
|
||||
0
|
||||
);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "AnswerOption" ("id", "questionId", "text", "isCorrect", "sortOrder") VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run("backup-option", "backup-question", "Correct option", 1, 0);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "SpacedRepetitionSet" ("id", "classId", "name", "sortOrder", "updatedAt") VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run("backup-srs", "backup-class", "Backup SRS", 0, Date.now());
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "SpacedRepetitionSetDeck" ("setId", "deckId", "sortOrder") VALUES (?, ?, ?)'
|
||||
)
|
||||
.run("backup-srs", "backup-deck", 0);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "ShareLink" ("id", "targetType", "groupId") VALUES (?, ?, ?)'
|
||||
)
|
||||
.run("backup-share", "GROUP", "backup-group");
|
||||
})();
|
||||
|
||||
const walPath = `${sourcePath}-wal`;
|
||||
if (!existsSync(walPath) || statSync(walPath).size === 0) {
|
||||
throw new Error("Backup drill did not create an active WAL database");
|
||||
}
|
||||
const sourceWalBytes = statSync(walPath).size;
|
||||
const sourceCounts = rowCounts(source);
|
||||
await backupDatabase(sourcePath, backupPath);
|
||||
copyFileSync(backupPath, restoredPath);
|
||||
source.close();
|
||||
source = undefined;
|
||||
|
||||
const restored = new Database(restoredPath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const integrity = restored.pragma("integrity_check", { simple: true });
|
||||
const foreignKeyErrors = restored.pragma("foreign_key_check");
|
||||
const restoredCounts = rowCounts(restored);
|
||||
const representative = restored
|
||||
.prepare(`
|
||||
SELECT c.name AS className, g.name AS groupName, d.description,
|
||||
f.front, s.name AS setName, sl.targetType
|
||||
FROM "Class" c
|
||||
JOIN "MaterialGroup" g ON g.classId = c.id
|
||||
JOIN "Deck" d ON d.groupId = g.id
|
||||
JOIN "Flashcard" f ON f.deckId = d.id
|
||||
JOIN "SpacedRepetitionSetDeck" sd ON sd.deckId = d.id
|
||||
JOIN "SpacedRepetitionSet" s ON s.id = sd.setId
|
||||
JOIN "ShareLink" sl ON sl.groupId = g.id
|
||||
WHERE d.id = ?
|
||||
`)
|
||||
.get("backup-deck");
|
||||
const quizRelationship = restored
|
||||
.prepare(`
|
||||
SELECT q.name AS quizName, question.prompt, option.text, option.isCorrect
|
||||
FROM "QuizSet" q
|
||||
JOIN "Question" question ON question.quizSetId = q.id
|
||||
JOIN "AnswerOption" option ON option.questionId = question.id
|
||||
WHERE q.id = ?
|
||||
`)
|
||||
.get("backup-quiz");
|
||||
if (
|
||||
integrity !== "ok" ||
|
||||
foreignKeyErrors.length !== 0 ||
|
||||
JSON.stringify(restoredCounts) !== JSON.stringify(sourceCounts) ||
|
||||
representative?.description !== "Restore drill content" ||
|
||||
representative?.setName !== "Backup SRS" ||
|
||||
representative?.targetType !== "GROUP" ||
|
||||
quizRelationship?.isCorrect !== 1
|
||||
) {
|
||||
throw new Error("Restored database did not preserve integrity, counts, content, and relationships");
|
||||
}
|
||||
console.info(
|
||||
JSON.stringify(
|
||||
{
|
||||
method: "better-sqlite3 online backup while the WAL source remained open",
|
||||
sourceWalBytes,
|
||||
integrity,
|
||||
foreignKeyErrors,
|
||||
counts: restoredCounts,
|
||||
representative,
|
||||
quizRelationship,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
restored.close();
|
||||
}
|
||||
} finally {
|
||||
source?.close();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const root = path.join(process.cwd(), ".test-databases");
|
||||
const directory = path.join(root, `http-smoke-${randomUUID()}`);
|
||||
if (!path.resolve(directory).startsWith(`${path.resolve(root)}${path.sep}`)) {
|
||||
throw new Error("Unsafe disposable HTTP smoke path");
|
||||
}
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const databasePath = path.join(directory, "http-smoke.test.db");
|
||||
const database = new Database(databasePath);
|
||||
try {
|
||||
database.pragma("foreign_keys = ON");
|
||||
for (const migration of readdirSync(path.join(process.cwd(), "prisma", "migrations"), { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort()) {
|
||||
database.exec(readFileSync(path.join(process.cwd(), "prisma", "migrations", migration, "migration.sql"), "utf8"));
|
||||
}
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
const port = 3789;
|
||||
const server = spawn(
|
||||
process.execPath,
|
||||
[path.join(process.cwd(), "node_modules", "next", "dist", "bin", "next"), "start", "-p", String(port)],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "production",
|
||||
DATABASE_URL: `file:${databasePath.replaceAll("\\", "/")}`,
|
||||
SESSION_SECRET: "http-smoke-secret-0123456789abcdef0123456789",
|
||||
ALLOW_INITIAL_SETUP: "true",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
}
|
||||
);
|
||||
let output = "";
|
||||
server.stdout.on("data", (chunk) => { output += chunk.toString(); });
|
||||
server.stderr.on("data", (chunk) => { output += chunk.toString(); });
|
||||
|
||||
try {
|
||||
let health;
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
try {
|
||||
health = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||
if (health.ok) break;
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
if (!health?.ok) throw new Error(`Health check did not become ready.\n${output}`);
|
||||
const login = await fetch(`http://127.0.0.1:${port}/login`);
|
||||
const setup = await fetch(`http://127.0.0.1:${port}/api/auth/setup-status`);
|
||||
const setupBody = await setup.json();
|
||||
const protectedDot = await fetch(`http://127.0.0.1:${port}/api/decks/file.json`, { redirect: "manual" });
|
||||
if (!login.ok || !setup.ok || setupBody.setupRequired !== true || setupBody.setupAllowed !== true || protectedDot.status !== 307) {
|
||||
throw new Error("HTTP smoke responses did not match the production contract");
|
||||
}
|
||||
console.info(JSON.stringify({
|
||||
health: health.status,
|
||||
login: login.status,
|
||||
setup: setupBody,
|
||||
protectedDot: { status: protectedDot.status, location: protectedDot.headers.get("location") },
|
||||
}, null, 2));
|
||||
if (process.argv.includes("--stay")) {
|
||||
console.info(`Browser smoke server ready at http://127.0.0.1:${port}/login`);
|
||||
await new Promise(() => {});
|
||||
}
|
||||
} finally {
|
||||
server.kill();
|
||||
await new Promise((resolve) => {
|
||||
if (server.exitCode !== null) return resolve();
|
||||
server.once("exit", resolve);
|
||||
setTimeout(resolve, 2_000);
|
||||
});
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ARCADE_RENDERERS } from "@/components/arcade/rendererRegistry";
|
||||
import { getArcadePack } from "@/services/arcadeService";
|
||||
|
||||
export default async function ConnectionsPlayPage(
|
||||
props: PageProps<"/[classSlug]/arcade/connections/[packId]/play">
|
||||
) {
|
||||
const [{ classSlug, packId }, query] = await Promise.all([props.params, props.searchParams]);
|
||||
const pack = await getArcadePack(packId);
|
||||
if (!pack || pack.class.slug !== classSlug || pack.gameType !== "connections" || pack.normalized.type !== "connections") notFound();
|
||||
const normalized = pack.normalized;
|
||||
const mistakesValue = Number(query.mistakes);
|
||||
const allowedMistakes = Number.isInteger(mistakesValue) && mistakesValue >= 1 && mistakesValue <= 8
|
||||
? mistakesValue
|
||||
: normalized.settings.allowedMistakes;
|
||||
const Renderer = ARCADE_RENDERERS.connections;
|
||||
return <Renderer seed={randomUUID()} packId={pack.id} packName={pack.name} classSlug={classSlug} pack={normalized} settings={{ allowedMistakes, oneAwayFeedback: query.oneAway !== "0" }} />;
|
||||
}
|
||||
12
src/app/(protected)/[classSlug]/arcade/connections/page.tsx
Normal file
12
src/app/(protected)/[classSlug]/arcade/connections/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { ConnectionsHub } from "@/components/arcade/ConnectionsHub";
|
||||
import { getClassBySlug } from "@/services/classService";
|
||||
import { listArcadePacks } from "@/services/arcadeService";
|
||||
|
||||
export default async function ConnectionsHubPage(props: PageProps<"/[classSlug]/arcade/connections">) {
|
||||
const { classSlug } = await props.params;
|
||||
const classItem = await getClassBySlug(classSlug);
|
||||
if (!classItem) notFound();
|
||||
const packs = await listArcadePacks(classItem.id, "connections");
|
||||
return <ConnectionsHub classId={classItem.id} classSlug={classSlug} initialPacks={packs} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ARCADE_RENDERERS } from "@/components/arcade/rendererRegistry";
|
||||
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
|
||||
import type { CrosswordSize, NormalizedCrosswordPack } from "@/types/arcade";
|
||||
import { getArcadePack } from "@/services/arcadeService";
|
||||
|
||||
const SIZES = new Set<CrosswordSize>(["mini", "standard", "large", "extra-large"]);
|
||||
|
||||
export default async function CrosswordPlayPage(props: { params: Promise<{ classSlug: string; packId: string }>; searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const [{ classSlug, packId }, query] = await Promise.all([props.params, props.searchParams]);
|
||||
const pack = await getArcadePack(packId);
|
||||
if (!pack || pack.class.slug !== classSlug || pack.gameType !== "crossword" || pack.normalized.type !== "crossword") notFound();
|
||||
const requestedSize = typeof query.size === "string" ? query.size : "standard";
|
||||
const size: CrosswordSize = SIZES.has(requestedSize as CrosswordSize) ? requestedSize as CrosswordSize : "standard";
|
||||
const seed = randomUUID();
|
||||
const normalized = pack.normalized as NormalizedCrosswordPack;
|
||||
const layout = generateCrosswordLayout(normalized, size, seed);
|
||||
const Renderer = ARCADE_RENDERERS.crossword;
|
||||
return <Renderer seed={seed} packId={pack.id} packName={pack.name} classSlug={classSlug} pack={normalized} layout={layout} settings={{ size, instantCheck: query.instant === "1", allowHints: query.hints !== "0" }} />;
|
||||
}
|
||||
12
src/app/(protected)/[classSlug]/arcade/crossword/page.tsx
Normal file
12
src/app/(protected)/[classSlug]/arcade/crossword/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { CrosswordHub } from "@/components/arcade/CrosswordHub";
|
||||
import { getClassBySlug } from "@/services/classService";
|
||||
import { listArcadePacks } from "@/services/arcadeService";
|
||||
|
||||
export default async function CrosswordHubPage(props: { params: Promise<{ classSlug: string }> }) {
|
||||
const { classSlug } = await props.params;
|
||||
const classItem = await getClassBySlug(classSlug);
|
||||
if (!classItem) notFound();
|
||||
const packs = await listArcadePacks(classItem.id, "crossword");
|
||||
return <CrosswordHub classId={classItem.id} classSlug={classSlug} initialPacks={packs} />;
|
||||
}
|
||||
3
src/app/(protected)/[classSlug]/arcade/layout.tsx
Normal file
3
src/app/(protected)/[classSlug]/arcade/layout.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function ArcadeLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="arcade-route">{children}</div>;
|
||||
}
|
||||
46
src/app/(protected)/[classSlug]/arcade/page.tsx
Normal file
46
src/app/(protected)/[classSlug]/arcade/page.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import Link from "next/link";
|
||||
import { ARCADE_GAMES } from "@/config/arcadeGames";
|
||||
|
||||
function ConnectionsVisual() {
|
||||
return <div className="arcade-visual connections-visual" aria-hidden><div className="connections-visual-grid">{Array.from({ length: 16 }, (_, index) => <i key={index} />)}</div><span className="connections-visual-label">4 hidden groups</span></div>;
|
||||
}
|
||||
|
||||
function CrosswordVisual() {
|
||||
const cells = ["C", "", "", "", "R", "", "O", "", "S", "T", "U", "D", "Y", "", "S", "", "", "", "W", "", "", "", "O", "", "", "", "R", "", "", "", "D", ""];
|
||||
return <div className="arcade-visual crossword-visual" aria-hidden><div className="crossword-grid">{cells.map((letter, index) => <i key={index} className={letter ? "has-letter" : ""}>{letter}</i>)}</div><span className="crossword-pencil">✎</span></div>;
|
||||
}
|
||||
|
||||
function FallingBlocksVisual() {
|
||||
return <div className="arcade-visual blocks-visual" aria-hidden><span className="blocks-arrow">↓</span><div className="blocks-piece blocks-piece-a"><i /><i /><i /><i /></div><div className="blocks-piece blocks-piece-b"><i /><i /><i /></div><div className="blocks-floor">{Array.from({ length: 9 }, (_, index) => <i key={index} />)}</div></div>;
|
||||
}
|
||||
|
||||
function AsteroidVisual() {
|
||||
return <div className="arcade-visual asteroid-visual" aria-hidden><i className="arcade-star star-a" /><i className="arcade-star star-b" /><i className="arcade-star star-c" /><span className="asteroid asteroid-a" /><span className="asteroid asteroid-b" /><span className="asteroid asteroid-c" /><span className="space-station"><i /></span><span className="laser-beam" /><span className="shield-ring" /></div>;
|
||||
}
|
||||
|
||||
function GameVisual({ gameKey }: { gameKey: (typeof ARCADE_GAMES)[number]["key"] }) {
|
||||
if (gameKey === "connections") return <ConnectionsVisual />;
|
||||
if (gameKey === "crossword") return <CrosswordVisual />;
|
||||
if (gameKey === "falling-blocks") return <FallingBlocksVisual />;
|
||||
return <AsteroidVisual />;
|
||||
}
|
||||
|
||||
export default async function ArcadePage(props: PageProps<"/[classSlug]/arcade">) {
|
||||
const { classSlug } = await props.params;
|
||||
return (
|
||||
<div className="arcade-lobby pb-20">
|
||||
<div className="arcade-lobby-heading mb-8">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.22em] text-primary">Insert curiosity</p>
|
||||
<h2 className="mt-2 text-4xl font-black tracking-[-0.05em] text-text-heading sm:text-5xl">Choose your cabinet</h2>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-text-secondary">Step away from the desk and turn your study material into a quick challenge.</p>
|
||||
</div>
|
||||
|
||||
<div className="arcade-cabinet-grid grid gap-5 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{ARCADE_GAMES.map((game) => {
|
||||
const card = <article className={`arcade-cabinet arcade-cabinet-${game.key} ${game.available ? "is-playable" : "is-coming"}`}><GameVisual gameKey={game.key} /><div className="arcade-cabinet-copy"><p className="arcade-cabinet-status">{game.estimatedMinutes}</p><h3>{game.name}</h3><p>{game.description}</p><span className="arcade-cabinet-action">{game.available ? `Play ${game.name} →` : "Coming soon"}</span></div></article>;
|
||||
return game.available ? <Link key={game.key} href={`/${classSlug}/arcade/${game.path}`} className="rounded-[1.75rem] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary">{card}</Link> : <div key={game.key}>{card}</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -25,8 +25,6 @@ interface DeckData {
|
|||
currentIndex: number;
|
||||
orderJson: string;
|
||||
cardResultsJson: string | null;
|
||||
sessionId: string;
|
||||
revision: number;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
}
|
||||
|
|
@ -44,24 +42,19 @@ export default function DeckStudyPage() {
|
|||
async function handleRestart() {
|
||||
if (!deck) return;
|
||||
|
||||
const responses = await Promise.all(
|
||||
deck.progress.map((progress) =>
|
||||
fetch("/api/progress", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contentType: "DECK",
|
||||
contentId: deck.id,
|
||||
mode: progress.mode,
|
||||
sessionId: progress.sessionId,
|
||||
}),
|
||||
})
|
||||
)
|
||||
).catch(() => null);
|
||||
if (!responses || responses.some((response) => !response.ok)) {
|
||||
window.alert("The session could not be restarted. Your saved progress was kept.");
|
||||
return;
|
||||
}
|
||||
// Clear progress in database for both modes
|
||||
await Promise.all([
|
||||
fetch("/api/progress", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SEQUENTIAL" })
|
||||
}).catch(() => {}),
|
||||
fetch("/api/progress", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SHUFFLED" })
|
||||
}).catch(() => {})
|
||||
]);
|
||||
|
||||
// Clear locally and force remount
|
||||
setDeck({ ...deck, progress: [] });
|
||||
|
|
@ -82,8 +75,7 @@ export default function DeckStudyPage() {
|
|||
}, [deckId, classSlug, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => void fetchDeck(), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
fetchDeck();
|
||||
}, [fetchDeck]);
|
||||
|
||||
if (loading || !deck) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ImportModal } from "@/components/import/ImportModal";
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragStartEvent,
|
||||
DragOverlay,
|
||||
useDroppable,
|
||||
|
|
@ -40,8 +41,6 @@ interface DeckItem {
|
|||
currentIndex: number;
|
||||
orderJson: string;
|
||||
cardResultsJson: string | null;
|
||||
sessionId: string;
|
||||
revision: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
|
@ -51,25 +50,15 @@ interface MaterialGroup {
|
|||
sortOrder: number;
|
||||
}
|
||||
|
||||
const cache: Record<string, { decks: DeckItem[]; groups: MaterialGroup[] }> = {};
|
||||
|
||||
function getProgressLabel(deck: DeckItem) {
|
||||
if (!deck.progress?.length) return null;
|
||||
const prog = deck.progress[0];
|
||||
let order: string[];
|
||||
let results: Record<string, string>;
|
||||
try {
|
||||
const parsedOrder: unknown = JSON.parse(prog.orderJson);
|
||||
const parsedResults: unknown = prog.cardResultsJson
|
||||
? JSON.parse(prog.cardResultsJson)
|
||||
: {};
|
||||
if (!Array.isArray(parsedOrder)) throw new Error("Invalid progress order");
|
||||
order = parsedOrder.filter((id): id is string => typeof id === "string");
|
||||
results =
|
||||
typeof parsedResults === "object" && parsedResults !== null
|
||||
? (parsedResults as Record<string, string>)
|
||||
: {};
|
||||
} catch {
|
||||
return "Saved session needs repair";
|
||||
}
|
||||
const order = JSON.parse(prog.orderJson) as string[];
|
||||
const results = prog.cardResultsJson
|
||||
? (JSON.parse(prog.cardResultsJson) as Record<string, string>)
|
||||
: {};
|
||||
const correctCount = Object.values(results).filter((r) => r === "correct").length;
|
||||
const total = order.length;
|
||||
const current = Math.min(prog.currentIndex + 1, total);
|
||||
|
|
@ -81,21 +70,10 @@ interface SortableDeckCardProps {
|
|||
deck: DeckItem;
|
||||
onEdit: (deck: DeckItem) => void;
|
||||
onDelete: (deck: DeckItem) => void;
|
||||
groups: MaterialGroup[];
|
||||
onMove: (deckId: string, groupId: string | null) => void;
|
||||
moveDisabled?: boolean;
|
||||
classSlug: string;
|
||||
}
|
||||
|
||||
function SortableDeckCard({
|
||||
deck,
|
||||
onEdit,
|
||||
onDelete,
|
||||
groups,
|
||||
onMove,
|
||||
moveDisabled = false,
|
||||
classSlug,
|
||||
}: SortableDeckCardProps) {
|
||||
function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCardProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: deck.id });
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
|
|
@ -139,24 +117,10 @@ function SortableDeckCard({
|
|||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm("Start over from the beginning? This will clear your current progress for this deck.")) return;
|
||||
const responses = await Promise.all(
|
||||
deck.progress.map((progress) =>
|
||||
fetch("/api/progress", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contentType: "DECK",
|
||||
contentId: deck.id,
|
||||
mode: progress.mode,
|
||||
sessionId: progress.sessionId,
|
||||
}),
|
||||
})
|
||||
)
|
||||
).catch(() => null);
|
||||
if (!responses || responses.some((response) => !response.ok)) {
|
||||
window.alert("The deck could not be restarted. Your saved progress was kept.");
|
||||
return;
|
||||
}
|
||||
await Promise.all([
|
||||
fetch("/api/progress", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SEQUENTIAL" }) }).catch(() => {}),
|
||||
fetch("/api/progress", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SHUFFLED" }) }).catch(() => {})
|
||||
]);
|
||||
window.location.href = `/${classSlug}/flashcards/${deck.id}`;
|
||||
}}
|
||||
className="flex-1 text-center py-2 px-4 rounded-lg bg-bg-surface-alt border border-border text-text-heading text-sm font-medium hover:bg-border transition-all duration-200 cursor-pointer shadow-sm"
|
||||
|
|
@ -172,20 +136,6 @@ function SortableDeckCard({
|
|||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<label className="sr-only" htmlFor={`move-deck-${deck.id}`}>Move {deck.name} to group</label>
|
||||
<select
|
||||
id={`move-deck-${deck.id}`}
|
||||
value={deck.groupId ?? ""}
|
||||
onChange={(event) => onMove(deck.id, event.target.value || null)}
|
||||
disabled={moveDisabled}
|
||||
className="min-h-9 max-w-36 rounded-lg border border-border bg-bg-surface-alt px-2 text-xs text-text-heading disabled:opacity-50"
|
||||
aria-label={`Move ${deck.name} to group`}
|
||||
>
|
||||
<option value="">Uncategorized</option>
|
||||
{groups.map((group) => (
|
||||
<option key={group.id} value={group.id}>{group.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={() => onEdit(deck)} className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer" title="Edit">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
|
|
@ -222,12 +172,10 @@ function DroppableContainer({ id, children }: { id: string; children: React.Reac
|
|||
export default function FlashcardsPage() {
|
||||
const params = useParams();
|
||||
const classSlug = params.classSlug as string;
|
||||
const [decks, setDecks] = useState<DeckItem[]>([]);
|
||||
const [groups, setGroups] = useState<MaterialGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const requestGeneration = useRef(0);
|
||||
const [decks, setDecks] = useState<DeckItem[]>(cache[classSlug]?.decks || []);
|
||||
const [groups, setGroups] = useState<MaterialGroup[]>(cache[classSlug]?.groups || []);
|
||||
const [loading, setLoading] = useState(!cache[classSlug]);
|
||||
const [animate] = useState(!cache[classSlug]);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [classId, setClassId] = useState<string>("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
|
@ -237,17 +185,13 @@ export default function FlashcardsPage() {
|
|||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
|
||||
const [editGroupName, setEditGroupName] = useState("");
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
const saved = localStorage.getItem('flashcards_collapsed_groups');
|
||||
if (saved) {
|
||||
try { setCollapsedGroups(JSON.parse(saved)); } catch {}
|
||||
}
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
const saved = localStorage.getItem('flashcards_collapsed_groups');
|
||||
if (saved) {
|
||||
try { setCollapsedGroups(JSON.parse(saved)); } catch(e) {}
|
||||
}
|
||||
}, []);
|
||||
|
||||
function toggleGroup(id: string) {
|
||||
|
|
@ -258,54 +202,43 @@ export default function FlashcardsPage() {
|
|||
});
|
||||
}
|
||||
|
||||
const fetchAll = useCallback(async (cId: string, generation = requestGeneration.current, signal?: AbortSignal) => {
|
||||
const fetchAll = useCallback(async (cId: string) => {
|
||||
try {
|
||||
const [deckRes, groupRes] = await Promise.all([
|
||||
fetch(`/api/decks/list?classId=${cId}`, { signal }),
|
||||
fetch(`/api/material-groups?classId=${cId}&type=DECK`, { signal }),
|
||||
fetch(`/api/decks/list?classId=${cId}`),
|
||||
fetch(`/api/material-groups?classId=${cId}&type=DECK`),
|
||||
]);
|
||||
if (!deckRes.ok || !groupRes.ok) throw new Error("Unable to load flashcard library");
|
||||
const ds = await deckRes.json();
|
||||
const gs = await groupRes.json();
|
||||
if (generation !== requestGeneration.current) return;
|
||||
const ds = deckRes.ok ? await deckRes.json() : [];
|
||||
const gs = groupRes.ok ? await groupRes.json() : [];
|
||||
setDecks(ds);
|
||||
setGroups(gs);
|
||||
setLoadError(null);
|
||||
} catch (error) {
|
||||
if (signal?.aborted || generation !== requestGeneration.current) return;
|
||||
setLoadError(error instanceof Error ? error.message : "Unable to load flashcard library");
|
||||
cache[classSlug] = { decks: ds, groups: gs };
|
||||
} catch {
|
||||
setDecks([]);
|
||||
setGroups([]);
|
||||
} finally {
|
||||
if (generation === requestGeneration.current) setLoading(false);
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [classSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const generation = ++requestGeneration.current;
|
||||
async function init() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
const classRes = await fetch("/api/classes", { signal: controller.signal });
|
||||
if (!classRes.ok) throw new Error("Unable to load class");
|
||||
const classRes = await fetch("/api/classes");
|
||||
const classes = await classRes.json();
|
||||
const cls = classes.find((c: { slug: string }) => c.slug === classSlug);
|
||||
if (!cls) throw new Error("Class not found");
|
||||
if (generation !== requestGeneration.current) return;
|
||||
if (!cls) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setClassId(cls.id);
|
||||
await fetchAll(cls.id, generation, controller.signal);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || generation !== requestGeneration.current) return;
|
||||
setLoadError(error instanceof Error ? error.message : "Unable to load flashcard library");
|
||||
fetchAll(cls.id);
|
||||
} catch {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
const timer = window.setTimeout(() => void init(), 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [classSlug, fetchAll, reloadKey]);
|
||||
init();
|
||||
}, [classSlug, fetchAll]);
|
||||
|
||||
// Group Management
|
||||
async function handleCreateGroup() {
|
||||
|
|
@ -317,71 +250,48 @@ export default function FlashcardsPage() {
|
|||
});
|
||||
if (res.ok) {
|
||||
const g = await res.json();
|
||||
setGroups((prev) => [g, ...prev]);
|
||||
setGroups((prev) => [...prev, g]);
|
||||
setIsCreatingGroup(false);
|
||||
setNewGroupName("");
|
||||
setActionMessage(`Created group ${g.name}.`);
|
||||
} else {
|
||||
setActionMessage("The group could not be created. Please retry.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRenameGroup(id: string) {
|
||||
if (!editGroupName.trim()) return;
|
||||
const response = await fetch(`/api/material-groups/${id}`, {
|
||||
await fetch(`/api/material-groups/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: editGroupName.trim() }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
setActionMessage("The group could not be renamed. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name: editGroupName.trim() } : g)));
|
||||
setEditingGroupId(null);
|
||||
setActionMessage("Group renamed.");
|
||||
}
|
||||
|
||||
async function handleDeleteGroup(id: string, name: string) {
|
||||
if (!confirm(`Delete group "${name}"? Decks inside will be moved to Uncategorized.`)) return;
|
||||
const response = await fetch(`/api/material-groups/${id}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
setActionMessage("The group could not be deleted. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
await fetchAll(classId);
|
||||
setActionMessage(`Deleted group ${name}; its decks are now Uncategorized.`);
|
||||
await fetch(`/api/material-groups/${id}`, { method: "DELETE" });
|
||||
setGroups((prev) => prev.filter((g) => g.id !== id));
|
||||
setDecks((prev) => prev.map((d) => (d.groupId === id ? { ...d, groupId: null } : d)));
|
||||
}
|
||||
|
||||
// Deck Management
|
||||
async function handleDeleteDeck(deck: DeckItem) {
|
||||
if (!confirm(`Delete "${deck.name}" and all its cards?`)) return;
|
||||
const response = await fetch(`/api/decks/${deck.id}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
setActionMessage("The deck could not be deleted. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
await fetch(`/api/decks/${deck.id}`, { method: "DELETE" });
|
||||
setDecks((prev) => prev.filter((d) => d.id !== deck.id));
|
||||
window.dispatchEvent(new Event("study-decks-changed"));
|
||||
setActionMessage(`Deleted deck ${deck.name}.`);
|
||||
}
|
||||
|
||||
async function handleRenameDeck(id: string) {
|
||||
if (!editName.trim()) return;
|
||||
const response = await fetch(`/api/decks/${id}`, {
|
||||
await fetch(`/api/decks/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: editName.trim(), description: editDescription.trim() || null }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
setActionMessage("The deck could not be updated. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
setDecks((prev) =>
|
||||
prev.map((d) => (d.id === id ? { ...d, name: editName.trim(), description: editDescription.trim() || null } : d))
|
||||
);
|
||||
setEditingId(null);
|
||||
setActionMessage("Deck updated.");
|
||||
}
|
||||
|
||||
// Drag and drop setup
|
||||
|
|
@ -391,13 +301,15 @@ export default function FlashcardsPage() {
|
|||
);
|
||||
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [isReordering, setIsReordering] = useState(false);
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
if (isReordering) return;
|
||||
setActiveId(event.active.id as string);
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragOverEvent) {
|
||||
// optional layout updates here
|
||||
}
|
||||
|
||||
async function handleDragEnd(event: DragEndEvent) {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
|
|
@ -422,76 +334,40 @@ export default function FlashcardsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const oldIndex = decks.findIndex((deck) => deck.id === activeId);
|
||||
const overIndex = decks.findIndex((deck) => deck.id === overId);
|
||||
let nextItems = decks.map((deck) =>
|
||||
deck.id === activeId ? { ...deck, groupId: targetGroupId } : { ...deck }
|
||||
);
|
||||
if (overIndex >= 0 && overIndex !== oldIndex) {
|
||||
nextItems = arrayMove(nextItems, oldIndex, overIndex);
|
||||
} else {
|
||||
const [moved] = nextItems.splice(oldIndex, 1);
|
||||
nextItems.push(moved);
|
||||
}
|
||||
await persistDeckReorder(
|
||||
decks,
|
||||
nextItems,
|
||||
new Set([activeDeck.groupId, targetGroupId])
|
||||
);
|
||||
}
|
||||
if (targetGroupId !== undefined) {
|
||||
setDecks((items) => {
|
||||
const oldIndex = items.findIndex((d) => d.id === activeId);
|
||||
const overIndex = items.findIndex((d) => d.id === overId);
|
||||
let newItems = [...items];
|
||||
|
||||
newItems[oldIndex].groupId = targetGroupId;
|
||||
|
||||
async function persistDeckReorder(
|
||||
previousItems: DeckItem[],
|
||||
proposedItems: DeckItem[],
|
||||
affectedGroups: Set<string | null>
|
||||
) {
|
||||
const counters = new Map<string | null, number>();
|
||||
const nextItems = proposedItems.map((item) => {
|
||||
if (!affectedGroups.has(item.groupId)) return item;
|
||||
const sortOrder = counters.get(item.groupId) ?? 0;
|
||||
counters.set(item.groupId, sortOrder + 1);
|
||||
return { ...item, sortOrder };
|
||||
});
|
||||
const updates: ReorderItem[] = nextItems
|
||||
.filter((item) => affectedGroups.has(item.groupId))
|
||||
.map((item) => ({ id: item.id, groupId: item.groupId }));
|
||||
if (overIndex >= 0 && overIndex !== oldIndex) {
|
||||
newItems = arrayMove(newItems, oldIndex, overIndex);
|
||||
} else {
|
||||
const oldItem = newItems.splice(oldIndex, 1)[0];
|
||||
newItems.push(oldItem);
|
||||
}
|
||||
|
||||
setDecks(nextItems);
|
||||
setIsReordering(true);
|
||||
try {
|
||||
const response = await fetch("/api/decks/reorder", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items: updates }),
|
||||
const affectedGroups = new Set([activeDeck.groupId, targetGroupId]);
|
||||
const updates: ReorderItem[] = [];
|
||||
|
||||
affectedGroups.forEach(gId => {
|
||||
const gItems = newItems.filter(d => d.groupId === gId);
|
||||
gItems.forEach((item, index) => {
|
||||
item.sortOrder = index;
|
||||
updates.push({ id: item.id, sortOrder: index, groupId: item.groupId });
|
||||
});
|
||||
});
|
||||
|
||||
fetch("/api/decks/reorder", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items: updates }),
|
||||
});
|
||||
|
||||
return newItems;
|
||||
});
|
||||
if (!response.ok) throw new Error("Reorder rejected");
|
||||
return true;
|
||||
} catch {
|
||||
setDecks(previousItems);
|
||||
window.alert("The deck move could not be saved. The library was refreshed.");
|
||||
await fetchAll(classId);
|
||||
return false;
|
||||
} finally {
|
||||
setIsReordering(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMoveDeck(deckId: string, targetGroupId: string | null) {
|
||||
if (isReordering) return;
|
||||
const activeDeck = decks.find((deck) => deck.id === deckId);
|
||||
if (!activeDeck || activeDeck.groupId === targetGroupId) return;
|
||||
const nextItems = decks
|
||||
.filter((deck) => deck.id !== deckId)
|
||||
.map((deck) => ({ ...deck }));
|
||||
nextItems.push({ ...activeDeck, groupId: targetGroupId });
|
||||
const saved = await persistDeckReorder(
|
||||
decks,
|
||||
nextItems,
|
||||
new Set([activeDeck.groupId, targetGroupId])
|
||||
);
|
||||
if (saved) {
|
||||
const destination = groups.find((group) => group.id === targetGroupId)?.name ?? "Uncategorized";
|
||||
setActionMessage(`Moved ${activeDeck.name} to ${destination}.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -505,11 +381,6 @@ export default function FlashcardsPage() {
|
|||
|
||||
return (
|
||||
<div className="pb-20">
|
||||
{actionMessage && (
|
||||
<p className="mb-4 rounded-xl bg-badge-bg px-4 py-3 text-sm text-text-secondary" role="status" aria-live="polite">
|
||||
{actionMessage}
|
||||
</p>
|
||||
)}
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Collection</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Flashcard decks</h2></div>
|
||||
|
|
@ -572,23 +443,15 @@ export default function FlashcardsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!loading && loadError && (
|
||||
<div role="alert" className="mb-6 rounded-2xl border border-error/30 bg-error-bg p-5 text-error">
|
||||
<p className="font-bold">Flashcard library could not be loaded.</p>
|
||||
<p className="mt-1 text-sm">{loadError}</p>
|
||||
<button onClick={() => setReloadKey((value) => value + 1)} className="mt-3 min-h-10 rounded-lg border border-error/40 px-4 text-sm font-bold">Retry</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !loadError && decks.length === 0 && groups.length === 0 && (
|
||||
{!loading && decks.length === 0 && groups.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<h3 className="text-lg font-semibold text-text-heading mb-1">No decks yet</h3>
|
||||
<p className="text-text-secondary mb-4">Import your first deck or create a group</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !loadError && (
|
||||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
{!loading && (
|
||||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
|
||||
<div className="space-y-8">
|
||||
{groupedDecks.map(group => (
|
||||
<div key={group.id} className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
|
||||
|
|
@ -622,7 +485,7 @@ export default function FlashcardsPage() {
|
|||
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">Drop decks here</div>
|
||||
) : (
|
||||
group.decks.map(deck => (
|
||||
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} groups={groups} onMove={handleMoveDeck} moveDisabled={isReordering} classSlug={classSlug} />
|
||||
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} classSlug={classSlug} />
|
||||
))
|
||||
)}
|
||||
</DroppableContainer>
|
||||
|
|
@ -647,7 +510,7 @@ export default function FlashcardsPage() {
|
|||
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">No uncategorized decks</div>
|
||||
) : (
|
||||
uncategorizedDecks.map(deck => (
|
||||
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} groups={groups} onMove={handleMoveDeck} moveDisabled={isReordering} classSlug={classSlug} />
|
||||
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} classSlug={classSlug} />
|
||||
))
|
||||
)}
|
||||
</DroppableContainer>
|
||||
|
|
@ -659,7 +522,7 @@ export default function FlashcardsPage() {
|
|||
<DragOverlay>
|
||||
{activeDeck ? (
|
||||
<div className="opacity-80 scale-105 shadow-xl rotate-2">
|
||||
<SortableDeckCard deck={activeDeck} onEdit={()=>{}} onDelete={()=>{}} groups={groups} onMove={()=>{}} moveDisabled classSlug={classSlug} />
|
||||
<SortableDeckCard deck={activeDeck} onEdit={()=>{}} onDelete={()=>{}} classSlug={classSlug} />
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ interface QuizAttempt {
|
|||
score: number;
|
||||
maxScore: number;
|
||||
answersJson: string;
|
||||
reviewSnapshotJson?: string | null;
|
||||
isPartialRetake: boolean;
|
||||
completedAt: string;
|
||||
}
|
||||
|
|
@ -43,8 +42,6 @@ interface QuizData {
|
|||
currentIndex: number;
|
||||
orderJson: string;
|
||||
answersJson: string | null;
|
||||
sessionId: string;
|
||||
revision: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
|
@ -63,23 +60,13 @@ export default function QuizStudyPage() {
|
|||
|
||||
async function handleRestart() {
|
||||
if (!quiz) return;
|
||||
const progress = quiz.progress.find((item) => item.mode === "SEQUENTIAL");
|
||||
if (progress) {
|
||||
const response = await fetch("/api/progress", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contentType: "QUIZ",
|
||||
contentId: quiz.id,
|
||||
mode: "SEQUENTIAL",
|
||||
sessionId: progress.sessionId,
|
||||
}),
|
||||
}).catch(() => null);
|
||||
if (!response?.ok) {
|
||||
window.alert("The quiz could not be restarted. Your saved progress was kept.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear progress in database
|
||||
await fetch("/api/progress", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType: "QUIZ", contentId: quiz.id, mode: "SEQUENTIAL" })
|
||||
}).catch(() => {});
|
||||
|
||||
// Clear locally and force remount
|
||||
setQuiz({ ...quiz, progress: [] });
|
||||
|
|
@ -117,8 +104,7 @@ export default function QuizStudyPage() {
|
|||
}, [quizId, classSlug, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => void fetchQuizAndAttempts(), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
fetchQuizAndAttempts();
|
||||
}, [fetchQuizAndAttempts]);
|
||||
|
||||
const [showTopics, setShowTopics] = useState(false);
|
||||
|
|
@ -235,7 +221,6 @@ export default function QuizStudyPage() {
|
|||
key={restartKey}
|
||||
quiz={quiz}
|
||||
retakeIds={retakeIds}
|
||||
sessionKey={restartKey}
|
||||
onFinished={() => {
|
||||
router.push(`/${classSlug}/quizzes`);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ImportModal } from "@/components/import/ImportModal";
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragStartEvent,
|
||||
DragOverlay,
|
||||
useDroppable,
|
||||
|
|
@ -40,8 +41,6 @@ interface QuizItem {
|
|||
currentIndex: number;
|
||||
orderJson: string;
|
||||
answersJson: string | null;
|
||||
sessionId: string;
|
||||
revision: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
|
@ -51,25 +50,16 @@ interface MaterialGroup {
|
|||
sortOrder: number;
|
||||
}
|
||||
|
||||
const cache: Record<string, { quizzes: QuizItem[]; groups: MaterialGroup[] }> = {};
|
||||
|
||||
interface SortableQuizCardProps {
|
||||
quiz: QuizItem;
|
||||
onEdit: (quiz: QuizItem) => void;
|
||||
onDelete: (quiz: QuizItem) => void;
|
||||
groups: MaterialGroup[];
|
||||
onMove: (quizId: string, groupId: string | null) => void;
|
||||
moveDisabled?: boolean;
|
||||
classSlug: string;
|
||||
}
|
||||
|
||||
function SortableQuizCard({
|
||||
quiz,
|
||||
onEdit,
|
||||
onDelete,
|
||||
groups,
|
||||
onMove,
|
||||
moveDisabled = false,
|
||||
classSlug,
|
||||
}: SortableQuizCardProps) {
|
||||
function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCardProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: quiz.id });
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
|
|
@ -113,22 +103,11 @@ function SortableQuizCard({
|
|||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm("Start over from the beginning? This will clear your current progress for this quiz.")) return;
|
||||
const progress = quiz.progress.find((item) => item.mode === "SEQUENTIAL");
|
||||
if (!progress) return;
|
||||
const response = await fetch("/api/progress", {
|
||||
await fetch("/api/progress", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contentType: "QUIZ",
|
||||
contentId: quiz.id,
|
||||
mode: "SEQUENTIAL",
|
||||
sessionId: progress.sessionId,
|
||||
}),
|
||||
}).catch(() => null);
|
||||
if (!response?.ok) {
|
||||
window.alert("The quiz could not be restarted. Your saved progress was kept.");
|
||||
return;
|
||||
}
|
||||
body: JSON.stringify({ contentType: "QUIZ", contentId: quiz.id, mode: "SEQUENTIAL" }),
|
||||
}).catch(() => {});
|
||||
window.location.href = `/${classSlug}/quizzes/${quiz.id}`;
|
||||
}}
|
||||
className="flex-1 cursor-pointer rounded-lg border border-border bg-bg-surface-alt px-4 py-2 text-center text-sm font-medium text-text-heading shadow-sm transition-all duration-200 hover:bg-border"
|
||||
|
|
@ -144,20 +123,6 @@ function SortableQuizCard({
|
|||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<label className="sr-only" htmlFor={`move-quiz-${quiz.id}`}>Move {quiz.name} to group</label>
|
||||
<select
|
||||
id={`move-quiz-${quiz.id}`}
|
||||
value={quiz.groupId ?? ""}
|
||||
onChange={(event) => onMove(quiz.id, event.target.value || null)}
|
||||
disabled={moveDisabled}
|
||||
className="min-h-9 max-w-36 rounded-lg border border-border bg-bg-surface-alt px-2 text-xs text-text-heading disabled:opacity-50"
|
||||
aria-label={`Move ${quiz.name} to group`}
|
||||
>
|
||||
<option value="">Uncategorized</option>
|
||||
{groups.map((group) => (
|
||||
<option key={group.id} value={group.id}>{group.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={() => onEdit(quiz)} className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer" title="Edit">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
|
|
@ -194,12 +159,10 @@ function DroppableContainer({ id, children }: { id: string; children: React.Reac
|
|||
export default function QuizzesPage() {
|
||||
const params = useParams();
|
||||
const classSlug = params.classSlug as string;
|
||||
const [quizzes, setQuizzes] = useState<QuizItem[]>([]);
|
||||
const [groups, setGroups] = useState<MaterialGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const requestGeneration = useRef(0);
|
||||
const [quizzes, setQuizzes] = useState<QuizItem[]>(cache[classSlug]?.quizzes || []);
|
||||
const [groups, setGroups] = useState<MaterialGroup[]>(cache[classSlug]?.groups || []);
|
||||
const [loading, setLoading] = useState(!cache[classSlug]);
|
||||
const [animate] = useState(!cache[classSlug]);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [classId, setClassId] = useState<string>("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
|
@ -209,17 +172,13 @@ export default function QuizzesPage() {
|
|||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
|
||||
const [editGroupName, setEditGroupName] = useState("");
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
const saved = localStorage.getItem('quizzes_collapsed_groups');
|
||||
if (saved) {
|
||||
try { setCollapsedGroups(JSON.parse(saved)); } catch {}
|
||||
}
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
const saved = localStorage.getItem('quizzes_collapsed_groups');
|
||||
if (saved) {
|
||||
try { setCollapsedGroups(JSON.parse(saved)); } catch(e) {}
|
||||
}
|
||||
}, []);
|
||||
|
||||
function toggleGroup(id: string) {
|
||||
|
|
@ -230,54 +189,43 @@ export default function QuizzesPage() {
|
|||
});
|
||||
}
|
||||
|
||||
const fetchAll = useCallback(async (cId: string, generation = requestGeneration.current, signal?: AbortSignal) => {
|
||||
const fetchAll = useCallback(async (cId: string) => {
|
||||
try {
|
||||
const [quizRes, groupRes] = await Promise.all([
|
||||
fetch(`/api/quizzes/list?classId=${cId}`, { signal }),
|
||||
fetch(`/api/material-groups?classId=${cId}&type=QUIZ`, { signal }),
|
||||
fetch(`/api/quizzes/list?classId=${cId}`),
|
||||
fetch(`/api/material-groups?classId=${cId}&type=QUIZ`),
|
||||
]);
|
||||
if (!quizRes.ok || !groupRes.ok) throw new Error("Unable to load quiz library");
|
||||
const qs = await quizRes.json();
|
||||
const gs = await groupRes.json();
|
||||
if (generation !== requestGeneration.current) return;
|
||||
const qs = quizRes.ok ? await quizRes.json() : [];
|
||||
const gs = groupRes.ok ? await groupRes.json() : [];
|
||||
setQuizzes(qs);
|
||||
setGroups(gs);
|
||||
setLoadError(null);
|
||||
} catch (error) {
|
||||
if (signal?.aborted || generation !== requestGeneration.current) return;
|
||||
setLoadError(error instanceof Error ? error.message : "Unable to load quiz library");
|
||||
cache[classSlug] = { quizzes: qs, groups: gs };
|
||||
} catch {
|
||||
setQuizzes([]);
|
||||
setGroups([]);
|
||||
} finally {
|
||||
if (generation === requestGeneration.current) setLoading(false);
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [classSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const generation = ++requestGeneration.current;
|
||||
async function init() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
const classRes = await fetch("/api/classes", { signal: controller.signal });
|
||||
if (!classRes.ok) throw new Error("Unable to load class");
|
||||
const classRes = await fetch("/api/classes");
|
||||
const classes = await classRes.json();
|
||||
const cls = classes.find((c: { slug: string }) => c.slug === classSlug);
|
||||
if (!cls) throw new Error("Class not found");
|
||||
if (generation !== requestGeneration.current) return;
|
||||
if (!cls) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setClassId(cls.id);
|
||||
await fetchAll(cls.id, generation, controller.signal);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || generation !== requestGeneration.current) return;
|
||||
setLoadError(error instanceof Error ? error.message : "Unable to load quiz library");
|
||||
fetchAll(cls.id);
|
||||
} catch {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
const timer = window.setTimeout(() => void init(), 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [classSlug, fetchAll, reloadKey]);
|
||||
init();
|
||||
}, [classSlug, fetchAll]);
|
||||
|
||||
// Group Management
|
||||
async function handleCreateGroup() {
|
||||
|
|
@ -289,70 +237,48 @@ export default function QuizzesPage() {
|
|||
});
|
||||
if (res.ok) {
|
||||
const g = await res.json();
|
||||
setGroups((prev) => [g, ...prev]);
|
||||
setGroups((prev) => [...prev, g]);
|
||||
setIsCreatingGroup(false);
|
||||
setNewGroupName("");
|
||||
setActionMessage(`Created group ${g.name}.`);
|
||||
} else {
|
||||
setActionMessage("The group could not be created. Please retry.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRenameGroup(id: string) {
|
||||
if (!editGroupName.trim()) return;
|
||||
const response = await fetch(`/api/material-groups/${id}`, {
|
||||
await fetch(`/api/material-groups/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: editGroupName.trim() }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
setActionMessage("The group could not be renamed. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name: editGroupName.trim() } : g)));
|
||||
setEditingGroupId(null);
|
||||
setActionMessage("Group renamed.");
|
||||
}
|
||||
|
||||
async function handleDeleteGroup(id: string, name: string) {
|
||||
if (!confirm(`Delete group "${name}"? Quizzes inside will be moved to Uncategorized.`)) return;
|
||||
const response = await fetch(`/api/material-groups/${id}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
setActionMessage("The group could not be deleted. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
await fetchAll(classId);
|
||||
setActionMessage(`Deleted group ${name}; its quizzes are now Uncategorized.`);
|
||||
await fetch(`/api/material-groups/${id}`, { method: "DELETE" });
|
||||
setGroups((prev) => prev.filter((g) => g.id !== id));
|
||||
setQuizzes((prev) => prev.map((q) => (q.groupId === id ? { ...q, groupId: null } : q)));
|
||||
}
|
||||
|
||||
// Quiz Management
|
||||
async function handleDeleteQuiz(quiz: QuizItem) {
|
||||
if (!confirm(`Delete "${quiz.name}" and all its questions and attempts?`)) return;
|
||||
const response = await fetch(`/api/quizzes/${quiz.id}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
setActionMessage("The quiz could not be deleted. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
await fetch(`/api/quizzes/${quiz.id}`, { method: "DELETE" });
|
||||
setQuizzes((prev) => prev.filter((q) => q.id !== quiz.id));
|
||||
setActionMessage(`Deleted quiz ${quiz.name}.`);
|
||||
}
|
||||
|
||||
async function handleRenameQuiz(id: string) {
|
||||
if (!editName.trim()) return;
|
||||
const response = await fetch(`/api/quizzes/${id}`, {
|
||||
await fetch(`/api/quizzes/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: editName.trim(), description: editDescription.trim() || null }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
setActionMessage("The quiz could not be updated. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
setQuizzes((prev) =>
|
||||
prev.map((q) => (q.id === id ? { ...q, name: editName.trim(), description: editDescription.trim() || null } : q))
|
||||
);
|
||||
setEditingId(null);
|
||||
setActionMessage("Quiz updated.");
|
||||
}
|
||||
|
||||
// Drag and drop setup
|
||||
|
|
@ -362,13 +288,26 @@ export default function QuizzesPage() {
|
|||
);
|
||||
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [isReordering, setIsReordering] = useState(false);
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
if (isReordering) return;
|
||||
setActiveId(event.active.id as string);
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragOverEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const activeId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
if (activeId === overId) return;
|
||||
|
||||
// Is it dropping over a container directly?
|
||||
const isOverContainer = over.data.current?.sortable?.containerId || overId;
|
||||
// ... we handle layout updates in dragEnd to keep it simple, or here for smooth lists.
|
||||
// For simplicity, we just handle the final move in dragEnd.
|
||||
}
|
||||
|
||||
async function handleDragEnd(event: DragEndEvent) {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
|
|
@ -394,76 +333,43 @@ export default function QuizzesPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const oldIndex = quizzes.findIndex((quiz) => quiz.id === activeId);
|
||||
const overIndex = quizzes.findIndex((quiz) => quiz.id === overId);
|
||||
let nextItems = quizzes.map((quiz) =>
|
||||
quiz.id === activeId ? { ...quiz, groupId: targetGroupId } : { ...quiz }
|
||||
);
|
||||
if (overIndex >= 0 && overIndex !== oldIndex) {
|
||||
nextItems = arrayMove(nextItems, oldIndex, overIndex);
|
||||
} else {
|
||||
const [moved] = nextItems.splice(oldIndex, 1);
|
||||
nextItems.push(moved);
|
||||
}
|
||||
await persistQuizReorder(
|
||||
quizzes,
|
||||
nextItems,
|
||||
new Set([activeQuiz.groupId, targetGroupId])
|
||||
);
|
||||
}
|
||||
if (targetGroupId !== undefined) {
|
||||
setQuizzes((items) => {
|
||||
const oldIndex = items.findIndex((q) => q.id === activeId);
|
||||
const overIndex = items.findIndex((q) => q.id === overId);
|
||||
let newItems = [...items];
|
||||
|
||||
newItems[oldIndex].groupId = targetGroupId;
|
||||
|
||||
async function persistQuizReorder(
|
||||
previousItems: QuizItem[],
|
||||
proposedItems: QuizItem[],
|
||||
affectedGroups: Set<string | null>
|
||||
) {
|
||||
const counters = new Map<string | null, number>();
|
||||
const nextItems = proposedItems.map((item) => {
|
||||
if (!affectedGroups.has(item.groupId)) return item;
|
||||
const sortOrder = counters.get(item.groupId) ?? 0;
|
||||
counters.set(item.groupId, sortOrder + 1);
|
||||
return { ...item, sortOrder };
|
||||
});
|
||||
const updates: ReorderItem[] = nextItems
|
||||
.filter((item) => affectedGroups.has(item.groupId))
|
||||
.map((item) => ({ id: item.id, groupId: item.groupId }));
|
||||
if (overIndex >= 0 && overIndex !== oldIndex) {
|
||||
newItems = arrayMove(newItems, oldIndex, overIndex);
|
||||
} else {
|
||||
// just moved to end of a group
|
||||
const oldItem = newItems.splice(oldIndex, 1)[0];
|
||||
newItems.push(oldItem);
|
||||
}
|
||||
|
||||
setQuizzes(nextItems);
|
||||
setIsReordering(true);
|
||||
try {
|
||||
const response = await fetch("/api/quizzes/reorder", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items: updates }),
|
||||
// Re-calculate sortOrder for the affected groups to persist
|
||||
const affectedGroups = new Set([activeQuiz.groupId, targetGroupId]);
|
||||
const updates: ReorderItem[] = [];
|
||||
|
||||
affectedGroups.forEach(gId => {
|
||||
const gItems = newItems.filter(q => q.groupId === gId);
|
||||
gItems.forEach((item, index) => {
|
||||
item.sortOrder = index;
|
||||
updates.push({ id: item.id, sortOrder: index, groupId: item.groupId });
|
||||
});
|
||||
});
|
||||
|
||||
// Fire API
|
||||
fetch("/api/quizzes/reorder", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items: updates }),
|
||||
});
|
||||
|
||||
return newItems;
|
||||
});
|
||||
if (!response.ok) throw new Error("Reorder rejected");
|
||||
return true;
|
||||
} catch {
|
||||
setQuizzes(previousItems);
|
||||
window.alert("The quiz move could not be saved. The library was refreshed.");
|
||||
await fetchAll(classId);
|
||||
return false;
|
||||
} finally {
|
||||
setIsReordering(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMoveQuiz(quizId: string, targetGroupId: string | null) {
|
||||
if (isReordering) return;
|
||||
const activeQuiz = quizzes.find((quiz) => quiz.id === quizId);
|
||||
if (!activeQuiz || activeQuiz.groupId === targetGroupId) return;
|
||||
const nextItems = quizzes
|
||||
.filter((quiz) => quiz.id !== quizId)
|
||||
.map((quiz) => ({ ...quiz }));
|
||||
nextItems.push({ ...activeQuiz, groupId: targetGroupId });
|
||||
const saved = await persistQuizReorder(
|
||||
quizzes,
|
||||
nextItems,
|
||||
new Set([activeQuiz.groupId, targetGroupId])
|
||||
);
|
||||
if (saved) {
|
||||
const destination = groups.find((group) => group.id === targetGroupId)?.name ?? "Uncategorized";
|
||||
setActionMessage(`Moved ${activeQuiz.name} to ${destination}.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -478,11 +384,6 @@ export default function QuizzesPage() {
|
|||
|
||||
return (
|
||||
<div className="pb-20">
|
||||
{actionMessage && (
|
||||
<p className="mb-4 rounded-xl bg-badge-bg px-4 py-3 text-sm text-text-secondary" role="status" aria-live="polite">
|
||||
{actionMessage}
|
||||
</p>
|
||||
)}
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Practice</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Practice quizzes</h2></div>
|
||||
|
|
@ -546,23 +447,15 @@ export default function QuizzesPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!loading && loadError && (
|
||||
<div role="alert" className="mb-6 rounded-2xl border border-error/30 bg-error-bg p-5 text-error">
|
||||
<p className="font-bold">Quiz library could not be loaded.</p>
|
||||
<p className="mt-1 text-sm">{loadError}</p>
|
||||
<button onClick={() => setReloadKey((value) => value + 1)} className="mt-3 min-h-10 rounded-lg border border-error/40 px-4 text-sm font-bold">Retry</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !loadError && quizzes.length === 0 && groups.length === 0 && (
|
||||
{!loading && quizzes.length === 0 && groups.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<h3 className="text-lg font-semibold text-text-heading mb-1">No quizzes yet</h3>
|
||||
<p className="text-text-secondary mb-4">Import your first quiz or create a group</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !loadError && (
|
||||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
{!loading && (
|
||||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
|
||||
<div className="space-y-8">
|
||||
{groupedQuizzes.map(group => (
|
||||
<div key={group.id} className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
|
||||
|
|
@ -583,7 +476,7 @@ export default function QuizzesPage() {
|
|||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<ShareMenu targetType="GROUP" contentId={group.id} classSlug={params.classSlug as string} containsQuizAnswers />
|
||||
<ShareMenu targetType="GROUP" contentId={group.id} classSlug={params.classSlug as string} />
|
||||
<button onClick={() => { setEditingGroupId(group.id); setEditGroupName(group.name); }} className="p-1.5 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer"><svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg></button>
|
||||
<button onClick={() => handleDeleteGroup(group.id, group.name)} className="p-1.5 rounded-lg text-text-muted hover:text-error hover:bg-bg-surface transition-colors cursor-pointer"><svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg></button>
|
||||
</div>
|
||||
|
|
@ -596,7 +489,7 @@ export default function QuizzesPage() {
|
|||
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">Drop quizzes here</div>
|
||||
) : (
|
||||
group.quizzes.map(quiz => (
|
||||
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} groups={groups} onMove={handleMoveQuiz} moveDisabled={isReordering} classSlug={classSlug} />
|
||||
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} classSlug={classSlug} />
|
||||
))
|
||||
)}
|
||||
</DroppableContainer>
|
||||
|
|
@ -621,7 +514,7 @@ export default function QuizzesPage() {
|
|||
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">No uncategorized quizzes</div>
|
||||
) : (
|
||||
uncategorizedQuizzes.map(quiz => (
|
||||
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} groups={groups} onMove={handleMoveQuiz} moveDisabled={isReordering} classSlug={classSlug} />
|
||||
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} classSlug={classSlug} />
|
||||
))
|
||||
)}
|
||||
</DroppableContainer>
|
||||
|
|
@ -633,7 +526,7 @@ export default function QuizzesPage() {
|
|||
<DragOverlay>
|
||||
{activeQuiz ? (
|
||||
<div className="opacity-80 scale-105 shadow-xl rotate-2">
|
||||
<SortableQuizCard quiz={activeQuiz} onEdit={()=>{}} onDelete={()=>{}} groups={groups} onMove={()=>{}} moveDisabled classSlug={classSlug} />
|
||||
<SortableQuizCard quiz={activeQuiz} onEdit={()=>{}} onDelete={()=>{}} classSlug={classSlug} />
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ const accents = ["#4f46e5", "#f97360", "#25845f", "#c27718", "#8b5cf6", "#0284c7
|
|||
export default function HomePage() {
|
||||
const [classes, setClasses] = useState<ClassItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
|
@ -28,25 +27,13 @@ export default function HomePage() {
|
|||
const [editName, setEditName] = useState("");
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetchClasses(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
useEffect(() => { fetchClasses(); }, []);
|
||||
|
||||
async function fetchClasses(signal?: AbortSignal) {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
async function fetchClasses() {
|
||||
try {
|
||||
const res = await fetch("/api/classes", { signal });
|
||||
if (!res.ok) throw new Error("The server could not load your classes.");
|
||||
const res = await fetch("/api/classes");
|
||||
setClasses(await res.json());
|
||||
} catch (error) {
|
||||
if (signal?.aborted) return;
|
||||
setLoadError(error instanceof Error ? error.message : "Unable to load classes.");
|
||||
} finally {
|
||||
if (!signal?.aborted) setLoading(false);
|
||||
}
|
||||
} finally { setLoading(false); }
|
||||
}
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
|
|
@ -55,21 +42,14 @@ export default function HomePage() {
|
|||
setCreating(true);
|
||||
try {
|
||||
const res = await fetch("/api/classes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newName.trim() }) });
|
||||
if (!res.ok) throw new Error("The class could not be created.");
|
||||
setNewName(""); setShowCreate(false); void fetchClasses();
|
||||
} catch (error) {
|
||||
setLoadError(error instanceof Error ? error.message : "The class could not be created.");
|
||||
if (res.ok) { setNewName(""); setShowCreate(false); fetchClasses(); }
|
||||
} finally { setCreating(false); }
|
||||
}
|
||||
|
||||
async function handleDelete(id: string, name: string) {
|
||||
if (!confirm(`Delete "${name}" and all its decks and quizzes?`)) return;
|
||||
const response = await fetch(`/api/classes/${id}`, { method: "DELETE" }).catch(() => null);
|
||||
if (!response?.ok) {
|
||||
setLoadError("The class could not be deleted. Retry when the server is available.");
|
||||
return;
|
||||
}
|
||||
void fetchClasses();
|
||||
await fetch(`/api/classes/${id}`, { method: "DELETE" });
|
||||
fetchClasses();
|
||||
}
|
||||
|
||||
async function handleSaveEdit(id: string) {
|
||||
|
|
@ -77,10 +57,7 @@ export default function HomePage() {
|
|||
setSavingEdit(true);
|
||||
try {
|
||||
const res = await fetch(`/api/classes/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: editName.trim() }) });
|
||||
if (!res.ok) throw new Error("The class could not be renamed.");
|
||||
setEditingId(null); void fetchClasses();
|
||||
} catch (error) {
|
||||
setLoadError(error instanceof Error ? error.message : "The class could not be renamed.");
|
||||
if (res.ok) { setEditingId(null); fetchClasses(); }
|
||||
} finally { setSavingEdit(false); }
|
||||
}
|
||||
|
||||
|
|
@ -116,15 +93,7 @@ export default function HomePage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!loading && loadError && (
|
||||
<div role="alert" className="rounded-2xl border border-error/30 bg-error-bg p-6 text-error">
|
||||
<p className="font-bold">Your study spaces could not be loaded.</p>
|
||||
<p className="mt-1 text-sm">{loadError}</p>
|
||||
<button onClick={() => void fetchClasses()} className="mt-4 min-h-10 rounded-lg border border-error/40 px-4 text-sm font-bold">Retry</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !loadError && classes.length === 0 && (
|
||||
{!loading && classes.length === 0 && (
|
||||
<div className="paper-grid rounded-[2rem] border border-dashed border-border bg-bg-surface px-6 py-16 text-center">
|
||||
<div className="mx-auto mb-5 grid h-14 w-14 place-items-center rounded-2xl bg-bg-callout text-2xl text-primary">+</div>
|
||||
<h2 className="editorial-title text-2xl text-text-heading">Your desk is ready</h2>
|
||||
|
|
@ -133,7 +102,7 @@ export default function HomePage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !loadError && classes.length > 0 && (
|
||||
{!loading && classes.length > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{classes.map((cls, index) => (
|
||||
<article key={cls.id} className="group relative overflow-hidden rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)] transition-all hover:-translate-y-1 hover:shadow-[var(--shadow-card-hover)]">
|
||||
|
|
|
|||
19
src/app/api/arcade/import/preview/route.ts
Normal file
19
src/app/api/arcade/import/preview/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
|
||||
import { arcadePreviewRequestSchema } from "@/lib/validation/arcadeSchemas";
|
||||
import * as arcadeService from "@/services/arcadeService";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const parsed = arcadePreviewRequestSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "A game type and raw JSON are required." }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
return NextResponse.json(arcadeService.previewArcadeImport(parsed.data.gameType, parsed.data.rawJson));
|
||||
} catch (error) {
|
||||
if (error instanceof ArcadeImportError) {
|
||||
return NextResponse.json({ error: error.message, details: error.details }, { status: 400 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
42
src/app/api/arcade/packs/[id]/attempts/route.ts
Normal file
42
src/app/api/arcade/packs/[id]/attempts/route.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { arcadeAttemptCreateSchema, crosswordAttemptCreateSchema } from "@/lib/validation/arcadeSchemas";
|
||||
import * as arcadeService from "@/services/arcadeService";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
context: RouteContext<"/api/arcade/packs/[id]/attempts">
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const pack = await arcadeService.getArcadePack(id);
|
||||
if (!pack) return NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
|
||||
return NextResponse.json(await arcadeService.listArcadeAttempts(id));
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: RouteContext<"/api/arcade/packs/[id]/attempts">
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const pack = await arcadeService.getArcadePack(id);
|
||||
if (!pack) return NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = pack.gameType === "crossword"
|
||||
? crosswordAttemptCreateSchema.safeParse(body)
|
||||
: arcadeAttemptCreateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid completed attempt", details: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const attempt = pack.gameType === "crossword"
|
||||
? await arcadeService.createCrosswordAttempt(id, parsed.data as Parameters<typeof arcadeService.createCrosswordAttempt>[1])
|
||||
: await arcadeService.createArcadeAttempt(id, parsed.data as Parameters<typeof arcadeService.createArcadeAttempt>[1]);
|
||||
return attempt
|
||||
? NextResponse.json(attempt, { status: 201 })
|
||||
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Attempt could not be saved" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
23
src/app/api/arcade/packs/[id]/layout/route.ts
Normal file
23
src/app/api/arcade/packs/[id]/layout/route.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
|
||||
import type { CrosswordSize } from "@/types/arcade";
|
||||
import { getArcadePack } from "@/services/arcadeService";
|
||||
|
||||
const CROSSWORD_SIZES = new Set<CrosswordSize>(["mini", "standard", "large", "extra-large"]);
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const sizeValue = request.nextUrl.searchParams.get("size") ?? "standard";
|
||||
if (!CROSSWORD_SIZES.has(sizeValue as CrosswordSize)) {
|
||||
return NextResponse.json({ error: "A valid Crossword size is required." }, { status: 400 });
|
||||
}
|
||||
const pack = await getArcadePack(id);
|
||||
if (!pack || pack.gameType !== "crossword" || pack.normalized.type !== "crossword") {
|
||||
return NextResponse.json({ error: "Crossword pack not found" }, { status: 404 });
|
||||
}
|
||||
const size = sizeValue as CrosswordSize;
|
||||
return NextResponse.json(generateCrosswordLayout(pack.normalized, size, `crossword-hub-preview-v1:${id}:${size}`));
|
||||
}
|
||||
38
src/app/api/arcade/packs/[id]/route.ts
Normal file
38
src/app/api/arcade/packs/[id]/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { arcadePackUpdateSchema } from "@/lib/validation/arcadeSchemas";
|
||||
import * as arcadeService from "@/services/arcadeService";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
context: RouteContext<"/api/arcade/packs/[id]">
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const pack = await arcadeService.getArcadePack(id);
|
||||
return pack
|
||||
? NextResponse.json(pack)
|
||||
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
context: RouteContext<"/api/arcade/packs/[id]">
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const parsed = arcadePackUpdateSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ error: "A valid name is required" }, { status: 400 });
|
||||
const pack = await arcadeService.updateArcadePack(id, parsed.data.name);
|
||||
return pack
|
||||
? NextResponse.json(pack)
|
||||
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
context: RouteContext<"/api/arcade/packs/[id]">
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const deleted = await arcadeService.deleteArcadePack(id);
|
||||
return deleted
|
||||
? NextResponse.json({ success: true })
|
||||
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
|
||||
}
|
||||
34
src/app/api/arcade/packs/route.ts
Normal file
34
src/app/api/arcade/packs/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
|
||||
import { arcadePackCreateSchema } from "@/lib/validation/arcadeSchemas";
|
||||
import * as arcadeService from "@/services/arcadeService";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const params = new URL(request.url).searchParams;
|
||||
const classId = params.get("classId");
|
||||
const gameType = params.get("gameType");
|
||||
if (!classId || (gameType !== "connections" && gameType !== "crossword")) {
|
||||
return NextResponse.json({ error: "Valid classId and gameType values are required." }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(await arcadeService.listArcadePacks(classId, gameType));
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const parsed = arcadePackCreateSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid Arcade pack request", details: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const packs = await arcadeService.createArcadePacks(parsed.data);
|
||||
return NextResponse.json({ packs, count: packs.length }, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof ArcadeImportError) {
|
||||
return NextResponse.json({ error: error.message, details: error.details }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
const status = error.message === "Class not found" ? 404 : 400;
|
||||
return NextResponse.json({ error: error.message }, { status });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,8 @@ import { checkRateLimit } from "@/lib/rateLimiter";
|
|||
import { login } from "@/services/authService";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!checkRateLimit("login:global").allowed) {
|
||||
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
if (!checkRateLimit(`login:${ip}`).allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests. Try again shortly." },
|
||||
{ status: 429 }
|
||||
|
|
|
|||
|
|
@ -1,20 +1,8 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { completePasswordResetSchema } from "@/lib/validation/authSchemas";
|
||||
import { checkRateLimit } from "@/lib/rateLimiter";
|
||||
import { completePasswordReset } from "@/services/authService";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (
|
||||
!checkRateLimit("password-reset-complete:global", {
|
||||
windowMs: 60_000,
|
||||
maxRequests: 5,
|
||||
}).allowed
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many attempts. Try again shortly." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
const parsed = completePasswordResetSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,11 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { checkRateLimit } from "@/lib/rateLimiter";
|
||||
import { requestPasswordReset } from "@/services/authService";
|
||||
|
||||
export async function POST() {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return NextResponse.json(
|
||||
{ error: "Start password recovery from the local server console." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const rateLimit = checkRateLimit("password-reset-request:global", {
|
||||
windowMs: 15 * 60_000,
|
||||
export async function POST(request: NextRequest) {
|
||||
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
const rateLimit = checkRateLimit(`password-reset-request:${ip}`, {
|
||||
windowMs: 60_000,
|
||||
maxRequests: 1,
|
||||
});
|
||||
if (!rateLimit.allowed) {
|
||||
|
|
@ -21,18 +15,11 @@ export async function POST() {
|
|||
);
|
||||
}
|
||||
|
||||
const result = await requestPasswordReset();
|
||||
if (result.status === "missing-password") {
|
||||
if (!(await requestPasswordReset())) {
|
||||
return NextResponse.json(
|
||||
{ error: "Password recovery is unavailable before initial setup." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
if (result.status === "active-token") {
|
||||
return NextResponse.json(
|
||||
{ error: "An unexpired reset token already exists." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ success: true, token: result.token });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { resetTokenSchema } from "@/lib/validation/authSchemas";
|
|||
import { verifyPasswordResetToken } from "@/services/authService";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!checkRateLimit("password-reset-verify:global").allowed) {
|
||||
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
if (!checkRateLimit(`password-reset-verify:${ip}`).allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many attempts. Try again shortly." },
|
||||
{ status: 429 }
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getSetupStatus } from "@/services/authService";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return NextResponse.json(await getSetupStatus());
|
||||
const setting = await prisma.setting.findUnique({
|
||||
where: { key: "admin_password_hash" },
|
||||
});
|
||||
|
||||
return NextResponse.json({ setupRequired: !setting });
|
||||
} catch (error) {
|
||||
console.error("Failed to check setup status:", error);
|
||||
return NextResponse.json(
|
||||
|
|
|
|||
|
|
@ -1,24 +1,21 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as cardService from "@/services/cardService";
|
||||
import { cardUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas";
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
ctx: RouteContext<"/api/cards/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const parsed = cardUpdateSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid card update", details: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
try {
|
||||
return NextResponse.json(await cardService.updateCard(id, parsed.data));
|
||||
} catch (error) {
|
||||
if (isPrismaError(error, "P2025")) {
|
||||
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
||||
}
|
||||
console.error("Failed to update card", error);
|
||||
return NextResponse.json({ error: "Failed to update card" }, { status: 500 });
|
||||
const updated = await cardService.updateCard(id, {
|
||||
front: body?.front?.trim(),
|
||||
back: body?.back?.trim(),
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -27,14 +24,11 @@ export async function DELETE(
|
|||
ctx: RouteContext<"/api/cards/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
|
||||
try {
|
||||
await cardService.deleteCard(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (isPrismaError(error, "P2025")) {
|
||||
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
||||
}
|
||||
console.error("Failed to delete card", error);
|
||||
return NextResponse.json({ error: "Failed to delete card" }, { status: 500 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as classService from "@/services/classService";
|
||||
import { classUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas";
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
ctx: RouteContext<"/api/classes/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const parsed = classUpdateSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "A valid class name is required" }, { status: 400 });
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body || (!body.name && body.name !== "")) {
|
||||
return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
return NextResponse.json(await classService.updateClass(id, parsed.data));
|
||||
} catch (error) {
|
||||
if (isPrismaError(error, "P2025")) {
|
||||
return NextResponse.json({ error: "Class not found" }, { status: 404 });
|
||||
}
|
||||
console.error("Failed to update class", error);
|
||||
return NextResponse.json({ error: "Failed to update class" }, { status: 500 });
|
||||
const updated = await classService.updateClass(id, {
|
||||
name: body.name?.trim(),
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Class not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -27,14 +27,11 @@ export async function DELETE(
|
|||
ctx: RouteContext<"/api/classes/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
|
||||
try {
|
||||
await classService.deleteClass(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (isPrismaError(error, "P2025")) {
|
||||
return NextResponse.json({ error: "Class not found" }, { status: 404 });
|
||||
}
|
||||
console.error("Failed to delete class", error);
|
||||
return NextResponse.json({ error: "Failed to delete class" }, { status: 500 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Class not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,20 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as classService from "@/services/classService";
|
||||
import { classCreateSchema, isPrismaError } from "@/lib/validation/contentSchemas";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(await classService.listClasses());
|
||||
const classes = await classService.listClasses();
|
||||
return NextResponse.json(classes);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const parsed = classCreateSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Class name is required and must be 160 characters or fewer" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
return NextResponse.json(await classService.createClass(parsed.data.name), { status: 201 });
|
||||
} catch (error) {
|
||||
if (isPrismaError(error, "P2002")) {
|
||||
return NextResponse.json({ error: "A class with that slug already exists" }, { status: 409 });
|
||||
}
|
||||
console.error("Failed to create class", error);
|
||||
return NextResponse.json({ error: "Failed to create class" }, { status: 500 });
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body?.name || typeof body.name !== "string" || !body.name.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Class name is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const newClass = await classService.createClass(body.name.trim());
|
||||
return NextResponse.json(newClass, { status: 201 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,29 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as cardService from "@/services/cardService";
|
||||
import { cardContentSchema, isPrismaError } from "@/lib/validation/contentSchemas";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
ctx: RouteContext<"/api/decks/[id]/cards">
|
||||
) {
|
||||
const { id: deckId } = await ctx.params;
|
||||
const parsed = cardContentSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (
|
||||
!body?.front ||
|
||||
!body?.back ||
|
||||
typeof body.front !== "string" ||
|
||||
typeof body.back !== "string"
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Card front and back are required and must be within the content limit", details: parsed.error.issues },
|
||||
{ error: "front and back are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
return NextResponse.json(await cardService.createCard(deckId, parsed.data), { status: 201 });
|
||||
} catch (error) {
|
||||
if (isPrismaError(error, "P2003")) {
|
||||
return NextResponse.json({ error: "Deck not found" }, { status: 404 });
|
||||
}
|
||||
console.error("Failed to create card", error);
|
||||
return NextResponse.json({ error: "Failed to create card" }, { status: 500 });
|
||||
}
|
||||
|
||||
const card = await cardService.createCard(deckId, {
|
||||
front: body.front.trim(),
|
||||
back: body.back.trim(),
|
||||
});
|
||||
|
||||
return NextResponse.json(card, { status: 201 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as deckService from "@/services/deckService";
|
||||
import { contentUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
|
|
@ -21,21 +20,16 @@ export async function PATCH(
|
|||
ctx: RouteContext<"/api/decks/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const parsed = contentUpdateSchema.safeParse(await request.json().catch(() => null));
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid deck update", details: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
try {
|
||||
const updated = await deckService.updateDeck(id, parsed.data);
|
||||
const updated = await deckService.updateDeck(id, {
|
||||
name: body?.name?.trim(),
|
||||
description: body?.description,
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
if (isPrismaError(error, "P2025")) {
|
||||
return NextResponse.json({ error: "Deck not found" }, { status: 404 });
|
||||
}
|
||||
console.error("Failed to update deck", error);
|
||||
return NextResponse.json({ error: "Failed to update deck" }, { status: 500 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Deck not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -48,11 +42,7 @@ export async function DELETE(
|
|||
try {
|
||||
await deckService.deleteDeck(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (isPrismaError(error, "P2025")) {
|
||||
return NextResponse.json({ error: "Deck not found" }, { status: 404 });
|
||||
}
|
||||
console.error("Failed to delete deck", error);
|
||||
return NextResponse.json({ error: "Failed to delete deck" }, { status: 500 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Deck not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,32 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { reorderRequestSchema } from "@/lib/validation/reorderSchemas";
|
||||
import {
|
||||
ReorderConflictError,
|
||||
ReorderValidationError,
|
||||
reorderContent,
|
||||
} from "@/services/reorderService";
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const parsed = reorderRequestSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid reorder input" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
await reorderContent("DECK", parsed.data);
|
||||
const parsed = reorderRequestSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { items } = parsed.data;
|
||||
|
||||
// items should be an array of { id, sortOrder, groupId }
|
||||
// Using a transaction to perform all updates
|
||||
await prisma.$transaction(
|
||||
items.map((item) =>
|
||||
prisma.deck.update({
|
||||
where: { id: item.id },
|
||||
data: {
|
||||
sortOrder: item.sortOrder,
|
||||
groupId: item.groupId,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof ReorderValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
if (error instanceof ReorderConflictError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 409 });
|
||||
}
|
||||
console.error("Failed to reorder decks", error);
|
||||
return NextResponse.json({ error: "Failed to reorder decks" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,40 +1,35 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as deckService from "@/services/deckService";
|
||||
import { deckImportRequestSchema } from "@/lib/validation/importSchemas";
|
||||
import { readLimitedJson, RequestTooLargeError } from "@/lib/limitedJson";
|
||||
import { flashcardImportSchema } from "@/lib/validation/importSchemas";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await readLimitedJson(request);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestTooLargeError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 413 });
|
||||
}
|
||||
throw error;
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = deckImportRequestSchema.safeParse(body);
|
||||
const { classId, data, name, groupId } = body;
|
||||
|
||||
if (!classId || typeof classId !== "string") {
|
||||
return NextResponse.json({ error: "classId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Server-side validation — never trust client
|
||||
const parsed = flashcardImportSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid deck import", details: parsed.error.issues },
|
||||
{ error: "Validation failed", details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const deck = await deckService.createDeckFromImport(
|
||||
parsed.data.classId,
|
||||
parsed.data.data,
|
||||
parsed.data.name,
|
||||
parsed.data.groupId ?? null
|
||||
);
|
||||
return NextResponse.json(deck, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof deckService.DeckValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
console.error("Failed to create deck", error);
|
||||
return NextResponse.json({ error: "Failed to create deck" }, { status: 500 });
|
||||
}
|
||||
const deck = await deckService.createDeckFromImport(
|
||||
classId,
|
||||
parsed.data,
|
||||
name?.trim() || undefined,
|
||||
groupId || null
|
||||
);
|
||||
|
||||
return NextResponse.json(deck, { status: 201 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { checkApplicationHealth } from "@/services/healthService";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await checkApplicationHealth();
|
||||
return NextResponse.json({ status: "ok" });
|
||||
} catch (error) {
|
||||
console.error("Health check failed: database schema is unavailable", error);
|
||||
return NextResponse.json({ status: "unhealthy" }, { status: 503 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +1,42 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { materialGroupUpdateSchema } from "@/lib/validation/materialGroupSchemas";
|
||||
import {
|
||||
deleteMaterialGroup,
|
||||
renameMaterialGroup,
|
||||
} from "@/services/materialGroupService";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const parsed = materialGroupUpdateSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid group update" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
return NextResponse.json(await renameMaterialGroup(id, parsed.data.name));
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { name, sortOrder } = body;
|
||||
|
||||
const updateData: Prisma.MaterialGroupUpdateInput = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (sortOrder !== undefined) updateData.sortOrder = sortOrder;
|
||||
|
||||
const group = await prisma.materialGroup.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return NextResponse.json(group);
|
||||
} catch (error) {
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
error.code === "P2025"
|
||||
) {
|
||||
return NextResponse.json({ error: "Group not found" }, { status: 404 });
|
||||
}
|
||||
console.error("Failed to rename material group", error);
|
||||
return NextResponse.json({ error: "Failed to rename group" }, { status: 500 });
|
||||
return NextResponse.json({ error: "Failed to update material group" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
if (!(await deleteMaterialGroup(id))) {
|
||||
return NextResponse.json({ error: "Group not found" }, { status: 404 });
|
||||
try {
|
||||
const { id } = await params;
|
||||
await prisma.materialGroup.delete({
|
||||
where: { id },
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to delete material group" }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,57 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
materialGroupCreateSchema,
|
||||
materialGroupQuerySchema,
|
||||
} from "@/lib/validation/materialGroupSchemas";
|
||||
import {
|
||||
createMaterialGroup,
|
||||
listMaterialGroups,
|
||||
} from "@/services/materialGroupService";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const parsed = materialGroupQuerySchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid group query" }, { status: 400 });
|
||||
const { searchParams } = new URL(request.url);
|
||||
const classId = searchParams.get("classId");
|
||||
const type = searchParams.get("type");
|
||||
|
||||
if (!classId) {
|
||||
return NextResponse.json({ error: "classId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (type && type !== "DECK" && type !== "QUIZ") {
|
||||
return NextResponse.json({ error: "Invalid type" }, { status: 400 });
|
||||
}
|
||||
|
||||
const whereClause: Prisma.MaterialGroupWhereInput = { classId };
|
||||
if (type) {
|
||||
whereClause.type = type;
|
||||
}
|
||||
|
||||
try {
|
||||
const groups = await prisma.materialGroup.findMany({
|
||||
where: whereClause,
|
||||
orderBy: { sortOrder: "asc" },
|
||||
});
|
||||
return NextResponse.json(groups);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to fetch material groups" }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
await listMaterialGroups(parsed.data.classId, parsed.data.type)
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const parsed = materialGroupCreateSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid group" },
|
||||
{ status: 400 }
|
||||
);
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { classId, name, type } = body;
|
||||
|
||||
if (!classId || !name || (type !== "DECK" && type !== "QUIZ")) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const maxOrder = await prisma.materialGroup.aggregate({
|
||||
where: { classId, type },
|
||||
_max: { sortOrder: true },
|
||||
});
|
||||
const sortOrder = (maxOrder._max.sortOrder ?? -1) + 1;
|
||||
|
||||
const group = await prisma.materialGroup.create({
|
||||
data: { classId, name, type, sortOrder },
|
||||
});
|
||||
|
||||
return NextResponse.json(group, { status: 201 });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to create material group" }, { status: 500 });
|
||||
}
|
||||
const group = await createMaterialGroup(parsed.data);
|
||||
if (!group) {
|
||||
return NextResponse.json({ error: "Class not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(group, { status: 201 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,64 +1,66 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
progressDeleteSchema,
|
||||
progressPatchSchema,
|
||||
progressQuerySchema,
|
||||
} from "@/lib/validation/progressSchemas";
|
||||
import {
|
||||
ProgressConflictError,
|
||||
ProgressNotFoundError,
|
||||
ProgressValidationError,
|
||||
clearProgress,
|
||||
getProgress,
|
||||
saveProgress,
|
||||
} from "@/services/progressService";
|
||||
import * as progressService from "@/services/progressService";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const parsed = progressQuerySchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid progress query" }, { status: 400 });
|
||||
const { searchParams } = new URL(request.url);
|
||||
const contentType = searchParams.get("contentType") as "DECK" | "QUIZ";
|
||||
const contentId = searchParams.get("contentId");
|
||||
const mode = searchParams.get("mode") as "SEQUENTIAL" | "SHUFFLED";
|
||||
|
||||
if (!contentType || !contentId || !mode) {
|
||||
return NextResponse.json(
|
||||
{ error: "contentType, contentId, and mode are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(await getProgress(parsed.data));
|
||||
|
||||
const progress = await progressService.getProgress(
|
||||
contentType,
|
||||
contentId,
|
||||
mode
|
||||
);
|
||||
|
||||
return NextResponse.json(progress);
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const parsed = progressPatchSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body?.contentType || !body?.contentId || !body?.mode) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid progress" },
|
||||
{ error: "contentType, contentId, and mode are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
return NextResponse.json(await saveProgress(parsed.data));
|
||||
} catch (error) {
|
||||
if (error instanceof ProgressNotFoundError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
if (error instanceof ProgressConflictError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 409 });
|
||||
}
|
||||
if (error instanceof ProgressValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
console.error("Failed to save study progress", error);
|
||||
return NextResponse.json({ error: "Failed to save progress" }, { status: 500 });
|
||||
}
|
||||
|
||||
const progress = await progressService.upsertProgress({
|
||||
contentType: body.contentType,
|
||||
contentId: body.contentId,
|
||||
mode: body.mode,
|
||||
currentIndex: body.currentIndex ?? 0,
|
||||
orderJson: body.orderJson,
|
||||
answersJson: body.answersJson,
|
||||
cardResultsJson: body.cardResultsJson,
|
||||
});
|
||||
|
||||
return NextResponse.json(progress);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const parsed = progressDeleteSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body?.contentType || !body?.contentId || !body?.mode) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid progress deletion" },
|
||||
{ error: "contentType, contentId, and mode are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ cleared: await clearProgress(parsed.data) });
|
||||
|
||||
await progressService.clearProgress(
|
||||
body.contentType,
|
||||
body.contentId,
|
||||
body.mode
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,40 +1,65 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
QuizAttemptValidationError,
|
||||
QuizNotFoundError,
|
||||
listQuizAttempts,
|
||||
submitQuizAttempt,
|
||||
} from "@/services/quizService";
|
||||
import { quizAttemptSchema } from "@/lib/validation/attemptSchemas";
|
||||
import { getQuizSetWithQuestions, createQuizAttempt, listQuizAttempts } from "@/services/quizService";
|
||||
import { scoreQuiz } from "@/lib/scoring";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
ctx: RouteContext<"/api/quizzes/[id]/attempt">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const parsed = quizAttemptSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body || !body.answersJson) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid attempt" },
|
||||
{ error: "answersJson is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
return NextResponse.json(await submitQuizAttempt(id, parsed.data), {
|
||||
status: 201,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof QuizNotFoundError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
if (error instanceof QuizAttemptValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
console.error("Failed to submit quiz attempt", error);
|
||||
return NextResponse.json({ error: "Failed to submit attempt" }, { status: 500 });
|
||||
|
||||
const quizSet = await getQuizSetWithQuestions(id);
|
||||
if (!quizSet) {
|
||||
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Parse answers
|
||||
let answers: Record<string, string[]>;
|
||||
try {
|
||||
answers = JSON.parse(body.answersJson);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid answers JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Determine which questions were included in this attempt
|
||||
const isPartialRetake = body.isPartialRetake === true;
|
||||
|
||||
// If partial retake, we only score the questions that actually had answers provided
|
||||
// or that were explicitly passed in a questionIds array.
|
||||
// For simplicity, we filter the quizSet questions down to what's in the answers object
|
||||
// if it's a partial retake, though the client will only show those anyway.
|
||||
let questionsToScore = quizSet.questions;
|
||||
if (isPartialRetake) {
|
||||
const answeredIds = Object.keys(answers);
|
||||
questionsToScore = quizSet.questions.filter(q => answeredIds.includes(q.id));
|
||||
}
|
||||
|
||||
// Format questions for the scoring utility
|
||||
const formattedQuestions = questionsToScore.map((q) => ({
|
||||
id: q.id,
|
||||
type: q.type as "MULTIPLE_CHOICE" | "SATA",
|
||||
options: q.options.map((o) => ({ id: o.id, isCorrect: o.isCorrect })),
|
||||
}));
|
||||
|
||||
const { total, maxScore } = scoreQuiz(formattedQuestions, answers);
|
||||
|
||||
const attempt = await createQuizAttempt({
|
||||
quizSetId: id,
|
||||
score: total,
|
||||
maxScore: maxScore,
|
||||
answersJson: body.answersJson,
|
||||
isPartialRetake,
|
||||
});
|
||||
|
||||
return NextResponse.json(attempt, { status: 201 });
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as quizService from "@/services/quizService";
|
||||
import { contentUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
|
|
@ -21,21 +20,16 @@ export async function PATCH(
|
|||
ctx: RouteContext<"/api/quizzes/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const parsed = contentUpdateSchema.safeParse(await request.json().catch(() => null));
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid quiz update", details: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
try {
|
||||
const updated = await quizService.updateQuizSet(id, parsed.data);
|
||||
const updated = await quizService.updateQuizSet(id, {
|
||||
name: body?.name?.trim(),
|
||||
description: body?.description,
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
if (isPrismaError(error, "P2025")) {
|
||||
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
|
||||
}
|
||||
console.error("Failed to update quiz", error);
|
||||
return NextResponse.json({ error: "Failed to update quiz" }, { status: 500 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -48,11 +42,7 @@ export async function DELETE(
|
|||
try {
|
||||
await quizService.deleteQuizSet(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (isPrismaError(error, "P2025")) {
|
||||
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
|
||||
}
|
||||
console.error("Failed to delete quiz", error);
|
||||
return NextResponse.json({ error: "Failed to delete quiz" }, { status: 500 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,32 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { reorderRequestSchema } from "@/lib/validation/reorderSchemas";
|
||||
import {
|
||||
ReorderConflictError,
|
||||
ReorderValidationError,
|
||||
reorderContent,
|
||||
} from "@/services/reorderService";
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const parsed = reorderRequestSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid reorder input" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
await reorderContent("QUIZ", parsed.data);
|
||||
const parsed = reorderRequestSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { items } = parsed.data;
|
||||
|
||||
// items should be an array of { id, sortOrder, groupId }
|
||||
// Using a transaction to perform all updates
|
||||
await prisma.$transaction(
|
||||
items.map((item) =>
|
||||
prisma.quizSet.update({
|
||||
where: { id: item.id },
|
||||
data: {
|
||||
sortOrder: item.sortOrder,
|
||||
groupId: item.groupId,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof ReorderValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
if (error instanceof ReorderConflictError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 409 });
|
||||
}
|
||||
console.error("Failed to reorder quizzes", error);
|
||||
return NextResponse.json({ error: "Failed to reorder quizzes" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,35 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as quizService from "@/services/quizService";
|
||||
import { quizImportRequestSchema } from "@/lib/validation/importSchemas";
|
||||
import { readLimitedJson, RequestTooLargeError } from "@/lib/limitedJson";
|
||||
import { quizImportSchema } from "@/lib/validation/importSchemas";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await readLimitedJson(request);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestTooLargeError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 413 });
|
||||
}
|
||||
throw error;
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = quizImportRequestSchema.safeParse(body);
|
||||
const { classId, data, name, groupId } = body;
|
||||
|
||||
if (!classId || typeof classId !== "string") {
|
||||
return NextResponse.json({ error: "classId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Server-side validation
|
||||
const parsed = quizImportSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid quiz import. SATA questions need at least two correct options.",
|
||||
details: parsed.error.issues,
|
||||
},
|
||||
{ error: "Validation failed", details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const quiz = await quizService.createQuizSetFromImport(
|
||||
parsed.data.classId,
|
||||
parsed.data.data,
|
||||
parsed.data.name,
|
||||
parsed.data.groupId ?? null
|
||||
);
|
||||
return NextResponse.json(quiz, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof quizService.QuizContentValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
console.error("Failed to create quiz", error);
|
||||
return NextResponse.json({ error: "Failed to create quiz" }, { status: 500 });
|
||||
}
|
||||
const quizSet = await quizService.createQuizSetFromImport(
|
||||
classId,
|
||||
parsed.data,
|
||||
name?.trim() || undefined,
|
||||
groupId || null
|
||||
);
|
||||
|
||||
return NextResponse.json(quizSet, { status: 201 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import * as settingsService from "@/services/settingsService";
|
|||
|
||||
function getInstructionType(request: NextRequest): settingsService.LlmInstructionType | null {
|
||||
const type = new URL(request.url).searchParams.get("type") ?? "flashcards";
|
||||
return type === "flashcards" || type === "quizzes" ? type : null;
|
||||
return type === "flashcards" || type === "quizzes" || type === "connections" || type === "crossword" ? type : null;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
|
|
|
|||
|
|
@ -1,21 +1,20 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { shareTargetSchema } from "@/lib/validation/shareSchemas";
|
||||
import {
|
||||
ShareTargetNotFoundError,
|
||||
getShareLinkForContent,
|
||||
isContentSharedViaGroup,
|
||||
toggleShareLink,
|
||||
} from "@/services/shareService";
|
||||
import { toggleShareLink, getShareLinkForContent, isContentSharedViaGroup } from "@/services/shareService";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const parsed = shareTargetSchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid share target" }, { status: 400 });
|
||||
const { searchParams } = new URL(request.url);
|
||||
const targetType = searchParams.get("targetType") as "DECK" | "QUIZ" | "GROUP";
|
||||
const contentId = searchParams.get("contentId");
|
||||
|
||||
if (!targetType || !contentId) {
|
||||
return NextResponse.json(
|
||||
{ error: "targetType and contentId are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { targetType, contentId } = parsed.data;
|
||||
|
||||
const link = await getShareLinkForContent(targetType, contentId);
|
||||
|
||||
if (targetType === "DECK" || targetType === "QUIZ") {
|
||||
const groupShare = await isContentSharedViaGroup(targetType, contentId);
|
||||
if (groupShare) {
|
||||
|
|
@ -26,27 +25,20 @@ export async function GET(request: NextRequest) {
|
|||
});
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ token: link?.id ?? null, isGroupShared: false });
|
||||
|
||||
return NextResponse.json({ token: link?.id || null, isGroupShared: false });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const parsed = shareTargetSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid share target" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const link = await toggleShareLink(
|
||||
parsed.data.targetType,
|
||||
parsed.data.contentId
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body?.targetType || !body?.contentId) {
|
||||
return NextResponse.json(
|
||||
{ error: "targetType and contentId are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
return NextResponse.json({ token: link?.id ?? null });
|
||||
} catch (error) {
|
||||
if (error instanceof ShareTargetNotFoundError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
console.error("Failed to update share link", error);
|
||||
return NextResponse.json({ error: "Failed to update sharing" }, { status: 500 });
|
||||
}
|
||||
|
||||
const link = await toggleShareLink(body.targetType as "DECK" | "QUIZ" | "GROUP", body.contentId);
|
||||
return NextResponse.json({ token: link?.id || null });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -231,6 +231,537 @@
|
|||
color: transparent;
|
||||
}
|
||||
|
||||
/* Arcade routes take over the complete application shell, including navigation. */
|
||||
body:has(.arcade-route) {
|
||||
--theme-bg-base: #070b18;
|
||||
--theme-bg-surface: #11182a;
|
||||
--theme-bg-surface-alt: #19233a;
|
||||
--theme-bg-callout: #282357;
|
||||
--theme-primary: #9a8cff;
|
||||
--theme-primary-hover: #b5aaff;
|
||||
--theme-text-heading: #f9f7ff;
|
||||
--theme-text-body: #dbe2f4;
|
||||
--theme-text-secondary: #aab6d1;
|
||||
--theme-text-muted: #71809e;
|
||||
--theme-border: #34415c;
|
||||
--theme-border-light: #242f46;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 76% 4%, rgba(116, 91, 255, .2), transparent 28rem),
|
||||
radial-gradient(circle at 30% 92%, rgba(5, 198, 181, .11), transparent 34rem),
|
||||
linear-gradient(145deg, #080c19, #0c1221 58%, #0b1020);
|
||||
}
|
||||
|
||||
body:has(.arcade-route)::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
opacity: .11;
|
||||
background-image: radial-gradient(rgba(201, 211, 255, .7) .7px, transparent .7px);
|
||||
background-size: 22px 22px;
|
||||
}
|
||||
|
||||
body:has(.arcade-route) main { min-height: 100vh; }
|
||||
body:has(.arcade-route) .app-page { max-width: none; min-height: 100vh; }
|
||||
body:has(.arcade-route) .arcade-route { width: min(100%, 1360px); margin-inline: auto; }
|
||||
body:has(.arcade-route) .app-sidebar,
|
||||
body:has(.arcade-route) .app-mobile-header {
|
||||
border-color: rgba(138, 157, 201, .15);
|
||||
background: rgba(8, 13, 27, .9);
|
||||
box-shadow: 18px 0 55px rgba(0, 0, 0, .16);
|
||||
}
|
||||
|
||||
body:has(.connections-world) {
|
||||
background:
|
||||
radial-gradient(circle at 28% 0%, rgba(88, 101, 242, .34), transparent 36rem),
|
||||
radial-gradient(circle at 96% 28%, rgba(239, 71, 111, .16), transparent 30rem),
|
||||
linear-gradient(145deg, #0b1329, #0e1932 54%, #142440);
|
||||
}
|
||||
|
||||
.arcade-lobby-heading { position: relative; }
|
||||
.arcade-lobby-heading::after {
|
||||
content: "SELECT GAME";
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: .5rem;
|
||||
color: rgba(154, 140, 255, .08);
|
||||
font-size: clamp(3rem, 8vw, 7rem);
|
||||
font-weight: 950;
|
||||
letter-spacing: -.06em;
|
||||
line-height: .8;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.arcade-cabinet {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 30rem;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 202, .2);
|
||||
border-radius: 1.75rem;
|
||||
background: #11182a;
|
||||
box-shadow: inset 0 1px rgba(255,255,255,.06), 0 22px 55px rgba(0,0,0,.28);
|
||||
transition: transform .24s var(--ease-spring), box-shadow .24s ease, border-color .24s ease;
|
||||
}
|
||||
.arcade-cabinet.is-playable:hover { transform: translateY(-7px) rotate(-.3deg); border-color: rgba(181,170,255,.65); box-shadow: 0 30px 70px rgba(0,0,0,.38), 0 0 45px rgba(126,103,255,.13); }
|
||||
.arcade-cabinet.is-coming { filter: saturate(.82); }
|
||||
.arcade-visual { position: relative; height: 13.5rem; overflow: hidden; border-bottom: 1px solid rgba(255,255,255,.1); }
|
||||
.arcade-cabinet-copy { display: flex; flex: 1; flex-direction: column; padding: 1.25rem; }
|
||||
.arcade-cabinet-status { font-size: .65rem; font-weight: 900; letter-spacing: .18em; text-transform: uppercase; }
|
||||
.arcade-cabinet-copy h3 { margin-top: .35rem; color: white; font-size: 1.55rem; font-weight: 900; letter-spacing: -.04em; }
|
||||
.arcade-cabinet-copy > p:not(.arcade-cabinet-status) { margin-top: .55rem; flex: 1; color: #aab6d1; font-size: .86rem; line-height: 1.65; }
|
||||
.arcade-cabinet-action { display: grid; min-height: 2.9rem; margin-top: 1.1rem; place-items: center; border: 1px solid rgba(255,255,255,.13); border-radius: .9rem; color: #8d9ab5; background: rgba(5,9,20,.3); font-size: .8rem; font-weight: 850; }
|
||||
.is-playable .arcade-cabinet-action { color: #14152a; border-color: transparent; background: linear-gradient(135deg, #a89dff, #8170f5); box-shadow: 0 10px 28px rgba(126,103,255,.28); }
|
||||
|
||||
.connections-visual { display: grid; place-items: center; background: radial-gradient(circle at 50% 20%, rgba(180,162,255,.35), transparent 55%), linear-gradient(145deg,#342c64,#1d2042); }
|
||||
.connections-visual-grid { display: grid; width: 9.5rem; grid-template-columns: repeat(4, 1fr); gap: .38rem; transform: perspective(400px) rotateX(8deg) rotateZ(-2deg); }
|
||||
.connections-visual-grid i { aspect-ratio: 1; border-radius: .5rem; box-shadow: inset 0 1px rgba(255,255,255,.45), 0 5px 10px rgba(0,0,0,.22); }
|
||||
.connections-visual-grid i:nth-child(-n+4) { background: #ffd166; }
|
||||
.connections-visual-grid i:nth-child(n+5):nth-child(-n+8) { background: #5ee0a0; }
|
||||
.connections-visual-grid i:nth-child(n+9):nth-child(-n+12) { background: #5ac8fa; }
|
||||
.connections-visual-grid i:nth-child(n+13) { background: #b69cff; }
|
||||
.connections-visual-label { position: absolute; right: 1rem; bottom: .75rem; color: rgba(255,255,255,.6); font-size: .62rem; font-weight: 900; letter-spacing: .16em; text-transform: uppercase; }
|
||||
|
||||
.crossword-visual { display: grid; place-items: center; background: linear-gradient(150deg,#f4dca1,#c98c45); }
|
||||
.crossword-visual::before { content: ""; position: absolute; inset: 0; opacity: .12; background-image: linear-gradient(#412911 1px,transparent 1px),linear-gradient(90deg,#412911 1px,transparent 1px); background-size: 18px 18px; }
|
||||
.crossword-grid { display: grid; width: 10rem; grid-template-columns: repeat(8,1fr); gap: 2px; transform: rotate(-3deg); filter: drop-shadow(0 12px 12px rgba(71,38,10,.28)); }
|
||||
.crossword-grid i { display: grid; aspect-ratio: 1; place-items: center; background: #312419; color: transparent; font-size: .62rem; font-style: normal; font-weight: 950; }
|
||||
.crossword-grid i.has-letter { color: #322112; background: #fff9e9; }
|
||||
.crossword-pencil { position: absolute; right: 1.2rem; bottom: .55rem; color: #6d3e17; font-size: 3rem; transform: rotate(-24deg); text-shadow: 0 5px 8px rgba(64,35,10,.2); }
|
||||
.arcade-cabinet-crossword .arcade-cabinet-status { color: #f3bb62; }
|
||||
|
||||
.blocks-visual { background: linear-gradient(#071a20,#0a3436); }
|
||||
.blocks-visual::before { content: ""; position: absolute; inset: 0; opacity: .18; background-image: linear-gradient(rgba(74,222,128,.45) 1px,transparent 1px),linear-gradient(90deg,rgba(74,222,128,.45) 1px,transparent 1px); background-size: 24px 24px; }
|
||||
.blocks-piece { position: absolute; display: grid; gap: 3px; }
|
||||
.blocks-piece i,.blocks-floor i { border: 1px solid rgba(255,255,255,.35); border-radius: 4px; box-shadow: inset 0 0 8px rgba(255,255,255,.25),0 0 13px currentColor; }
|
||||
.blocks-piece-a { left: 32%; top: 18%; grid-template-columns: repeat(2,1.8rem); color: #38e6c5; animation: arcadeBlockHover 2s ease-in-out infinite; }
|
||||
.blocks-piece-a i { width: 1.8rem; height: 1.8rem; background: #0fcbaa; }
|
||||
.blocks-piece-b { right: 18%; top: 42%; grid-template-columns: repeat(3,1.55rem); color: #ff4f91; transform: rotate(90deg); }
|
||||
.blocks-piece-b i { width: 1.55rem; height: 1.55rem; background: #ed397e; }
|
||||
.blocks-floor { position: absolute; right: 12%; bottom: 1rem; left: 12%; display: grid; grid-template-columns: repeat(6,1fr); gap: 3px; }
|
||||
.blocks-floor i { aspect-ratio: 1; color: #ffc857; background: #eaaa2e; }
|
||||
.blocks-floor i:nth-child(3n) { color: #6c7cff; background: #5868ee; transform: translateY(-1.5rem); }
|
||||
.blocks-arrow { position: absolute; left: 19%; top: 20%; color: rgba(103,232,211,.5); font-size: 2.2rem; animation: arcadeArrowDown 1.3s ease-in-out infinite; }
|
||||
.arcade-cabinet-falling-blocks .arcade-cabinet-status { color: #49ddb6; }
|
||||
|
||||
.asteroid-visual { background: radial-gradient(circle at 72% 28%,#253c74,#101735 45%,#070b19); }
|
||||
.arcade-star { position: absolute; width: 3px; height: 3px; border-radius: 50%; background: white; box-shadow: 0 0 8px white; }
|
||||
.star-a { left: 16%; top: 18%; }.star-b { right: 18%; top: 12%; }.star-c { left: 48%; top: 39%; }
|
||||
.asteroid { position: absolute; border: 2px solid #8c91a6; border-radius: 47% 53% 42% 58%; background: radial-gradient(circle at 32% 28%,#9299ac,#4c5268 62%,#292e41); box-shadow: inset -7px -8px 12px rgba(0,0,0,.35),0 8px 18px rgba(0,0,0,.35); }
|
||||
.asteroid::after { content: ""; position: absolute; width: 28%; height: 24%; left: 18%; top: 22%; border-radius: 50%; background: rgba(32,37,55,.45); }
|
||||
.asteroid-a { width: 3.2rem; height: 3rem; right: 14%; top: 18%; transform: rotate(18deg); }.asteroid-b { width: 2rem; height: 2rem; left: 16%; top: 27%; }.asteroid-c { width: 1.4rem; height: 1.35rem; right: 32%; bottom: 18%; }
|
||||
.space-station { position: absolute; left: 20%; bottom: 18%; width: 4rem; height: 1.4rem; border-radius: 65% 20% 35% 65%; background: linear-gradient(90deg,#b9c8ef,#6f83bd); transform: rotate(-12deg); box-shadow: 0 0 20px rgba(105,151,255,.35); }
|
||||
.space-station::before { content: ""; position: absolute; left: 1.2rem; bottom: 1rem; border-right: 1rem solid transparent; border-bottom: 1.5rem solid #53699f; border-left: .25rem solid transparent; }
|
||||
.space-station i { position: absolute; right: -.75rem; top: .3rem; width: 1rem; height: .8rem; border-radius: 50%; background: #50e6ff; box-shadow: 0 0 16px #4fdff8; }
|
||||
.laser-beam { position: absolute; width: 38%; height: 2px; left: 42%; top: 52%; background: linear-gradient(90deg,#70f4ff,transparent); transform: rotate(-20deg); transform-origin: left; box-shadow: 0 0 8px #52e7ff; }
|
||||
.shield-ring { position: absolute; left: 8%; bottom: 4%; width: 7.5rem; height: 6rem; border: 2px solid rgba(90,221,255,.25); border-radius: 50%; transform: rotate(-15deg); }
|
||||
.arcade-cabinet-asteroid-defense .arcade-cabinet-status { color: #65c9ff; }
|
||||
|
||||
@keyframes arcadeBlockHover { 0%,100% { transform: translateY(0); } 50% { transform: translateY(8px); } }
|
||||
@keyframes arcadeArrowDown { 0%,100% { opacity: .3; transform: translateY(-5px); } 50% { opacity: .8; transform: translateY(6px); } }
|
||||
|
||||
/* Connections has its own playful game-show visual system, independent of the desk UI. */
|
||||
.connections-world {
|
||||
--theme-bg-surface: #182443;
|
||||
--theme-bg-surface-alt: #223154;
|
||||
--theme-bg-callout: #2c3d68;
|
||||
--theme-primary: #ffd166;
|
||||
--theme-primary-hover: #ffe29a;
|
||||
--theme-text-heading: #f8fbff;
|
||||
--theme-text-body: #dbe7ff;
|
||||
--theme-text-secondary: #afc0df;
|
||||
--theme-text-muted: #8497bd;
|
||||
--theme-border: #52668d;
|
||||
--theme-border-light: #33486f;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
color: var(--theme-text-body);
|
||||
background:
|
||||
radial-gradient(circle at 10% 0%, rgba(88, 101, 242, .45), transparent 28rem),
|
||||
radial-gradient(circle at 95% 25%, rgba(239, 71, 111, .2), transparent 25rem),
|
||||
linear-gradient(145deg, #101a35 0%, #111d39 48%, #172846 100%);
|
||||
box-shadow: 0 28px 80px rgba(8, 15, 34, .28);
|
||||
}
|
||||
|
||||
.connections-hub.connections-world,
|
||||
.connections-stage.connections-world {
|
||||
overflow: visible;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.connections-hub.connections-world::before,
|
||||
.connections-stage.connections-world::before { display: none; }
|
||||
|
||||
.connections-world::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
opacity: .13;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,.18) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.18) 1px, transparent 1px);
|
||||
background-size: 42px 42px;
|
||||
mask-image: linear-gradient(to bottom, black, transparent 78%);
|
||||
}
|
||||
|
||||
.connections-world .editorial-title {
|
||||
font-family: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
|
||||
font-weight: 850;
|
||||
letter-spacing: -.045em;
|
||||
}
|
||||
|
||||
.connections-hub-hero,
|
||||
.connections-topbar,
|
||||
.connections-status,
|
||||
.connections-setup,
|
||||
.connections-import {
|
||||
backdrop-filter: blur(18px);
|
||||
box-shadow: inset 0 1px rgba(255,255,255,.08), 0 18px 45px rgba(2, 8, 23, .22);
|
||||
}
|
||||
|
||||
.connections-hub-hero {
|
||||
background: linear-gradient(120deg, rgba(34,49,84,.94), rgba(24,36,67,.72));
|
||||
border: 1px solid rgba(151, 174, 221, .18);
|
||||
}
|
||||
|
||||
.connections-pack-card {
|
||||
border-color: rgba(117, 141, 187, .28);
|
||||
background: rgba(24, 36, 67, .74);
|
||||
box-shadow: inset 0 1px rgba(255,255,255,.05), 0 12px 30px rgba(3, 9, 24, .16);
|
||||
}
|
||||
|
||||
.connections-pack-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(255, 209, 102, .48);
|
||||
}
|
||||
|
||||
.connections-pack-card.is-active {
|
||||
border-color: #ffd166;
|
||||
background: linear-gradient(120deg, rgba(65, 72, 132, .9), rgba(38, 55, 96, .9));
|
||||
box-shadow: 0 0 0 1px rgba(255,209,102,.25), 0 18px 45px rgba(3,9,24,.25);
|
||||
}
|
||||
|
||||
.connections-primary-button {
|
||||
color: #172033;
|
||||
background: linear-gradient(135deg, #ffd166, #ffb84d);
|
||||
box-shadow: 0 10px 26px rgba(255, 184, 77, .24), inset 0 1px rgba(255,255,255,.5);
|
||||
transition: transform .18s var(--ease-spring), filter .18s ease, box-shadow .18s ease;
|
||||
}
|
||||
|
||||
.connections-primary-button:not(:disabled):hover {
|
||||
transform: translateY(-2px) scale(1.01);
|
||||
filter: brightness(1.06);
|
||||
box-shadow: 0 14px 34px rgba(255, 184, 77, .32), inset 0 1px rgba(255,255,255,.6);
|
||||
}
|
||||
|
||||
.connections-secondary-button {
|
||||
color: #dbe7ff;
|
||||
border: 1px solid #52668d;
|
||||
background: rgba(24, 36, 67, .82);
|
||||
transition: transform .18s var(--ease-spring), background .18s ease;
|
||||
}
|
||||
|
||||
.connections-secondary-button:not(:disabled):hover { transform: translateY(-2px); background: #2c3d68; }
|
||||
|
||||
.connections-tile {
|
||||
color: #15213a;
|
||||
border: 1px solid rgba(255,255,255,.75);
|
||||
background: linear-gradient(145deg, #fffaf0, #e9effa);
|
||||
box-shadow: 0 7px 0 #aebbd2, 0 12px 24px rgba(2,8,23,.24), inset 0 1px #fff;
|
||||
animation: connectionsTileIn .36s var(--ease-spring) both;
|
||||
transition: transform .16s var(--ease-spring), box-shadow .16s ease, color .16s ease, background .16s ease;
|
||||
}
|
||||
|
||||
.connections-tile:hover { transform: translateY(-3px); box-shadow: 0 9px 0 #aebbd2, 0 16px 30px rgba(2,8,23,.28), inset 0 1px #fff; }
|
||||
.connections-tile.is-selected {
|
||||
color: #fff;
|
||||
border-color: #8c7cf7;
|
||||
background: linear-gradient(145deg, #735df2, #5540ca);
|
||||
box-shadow: 0 5px 0 #30228e, 0 12px 28px rgba(84,64,202,.4), inset 0 1px rgba(255,255,255,.28);
|
||||
transform: translateY(2px) scale(.975);
|
||||
animation: connectionsTileSelect .25s var(--ease-spring);
|
||||
}
|
||||
|
||||
.connections-group,
|
||||
.connections-review-card {
|
||||
color: #172033;
|
||||
box-shadow: inset 0 1px rgba(255,255,255,.55), 0 10px 26px rgba(2,8,23,.2);
|
||||
}
|
||||
.connections-group .text-text-heading,
|
||||
.connections-group .text-text-secondary,
|
||||
.connections-review-card .text-text-heading,
|
||||
.connections-review-card .text-text-secondary { color: #172033; }
|
||||
.connections-group { animation: connectionsGroupReveal .56s var(--ease-spring) both; }
|
||||
.connections-group-0 { background: linear-gradient(135deg, #ffd166, #f7b944); }
|
||||
.connections-group-1 { background: linear-gradient(135deg, #70e1a1, #36bd7c); }
|
||||
.connections-group-2 { background: linear-gradient(135deg, #72d5f7, #4aa8e8); }
|
||||
.connections-group-3 { background: linear-gradient(135deg, #b9a2ff, #8d73ed); }
|
||||
|
||||
.connections-results-hero {
|
||||
background: linear-gradient(130deg, #5b46d8, #283d77 58%, #164e63);
|
||||
box-shadow: inset 0 1px rgba(255,255,255,.18), 0 22px 50px rgba(2,8,23,.3);
|
||||
}
|
||||
|
||||
.connections-completion {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: 32rem;
|
||||
place-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: 1.75rem;
|
||||
text-align: center;
|
||||
background: radial-gradient(circle, rgba(94,234,212,.2), transparent 42%), rgba(10,18,39,.72);
|
||||
animation: connectionsCompletionIn .55s var(--ease-spring) both;
|
||||
}
|
||||
.connections-completion-mark {
|
||||
display: grid;
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
margin: 0 auto 1.5rem;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
color: #172033;
|
||||
background: #ffd166;
|
||||
box-shadow: 0 0 0 12px rgba(255,209,102,.12), 0 0 65px rgba(255,209,102,.46);
|
||||
font-size: 3rem;
|
||||
font-weight: 900;
|
||||
animation: connectionsWinMark .8s .15s var(--ease-spring) both;
|
||||
}
|
||||
.connections-completion p { color: #8ee7d1; font-size: .75rem; font-weight: 850; letter-spacing: .2em; text-transform: uppercase; }
|
||||
.connections-completion h2 { margin-top: .35rem; color: white; font-size: clamp(2.5rem, 8vw, 5.5rem); font-weight: 900; letter-spacing: -.065em; line-height: .95; }
|
||||
.connections-completion span { margin-top: 1rem; color: #b9c8e5; font-weight: 700; }
|
||||
|
||||
.connections-confetti { position: absolute; inset: 0; pointer-events: none; }
|
||||
.connections-confetti i {
|
||||
position: absolute;
|
||||
top: -8%;
|
||||
left: var(--confetti-x);
|
||||
width: 9px;
|
||||
height: 17px;
|
||||
border-radius: 2px;
|
||||
background: var(--confetti-color);
|
||||
animation: connectionsConfettiFall 1.45s var(--confetti-delay) cubic-bezier(.2,.7,.3,1) both;
|
||||
}
|
||||
|
||||
@keyframes connectionsTileIn { from { opacity: 0; transform: translateY(16px) scale(.94); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
@keyframes connectionsTileSelect { 0% { transform: scale(1); } 55% { transform: translateY(3px) scale(.94); } 100% { transform: translateY(2px) scale(.975); } }
|
||||
@keyframes connectionsShake { 0%,100% { transform: translateX(0); } 20% { transform: translateX(-9px); } 40% { transform: translateX(8px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(3px); } }
|
||||
@keyframes connectionsGroupReveal { from { opacity: 0; transform: scale(.9) translateY(18px); filter: brightness(1.6); } 65% { transform: scale(1.025) translateY(-2px); } to { opacity: 1; transform: scale(1) translateY(0); filter: brightness(1); } }
|
||||
@keyframes connectionsCompletionIn { from { opacity: 0; transform: scale(.94); } to { opacity: 1; transform: scale(1); } }
|
||||
@keyframes connectionsWinMark { from { opacity: 0; transform: scale(.2) rotate(-25deg); } 70% { transform: scale(1.12) rotate(4deg); } to { opacity: 1; transform: scale(1) rotate(0); } }
|
||||
@keyframes connectionsConfettiFall { 0% { opacity: 0; transform: translateY(-1rem) rotate(0); } 10% { opacity: 1; } 100% { opacity: 1; transform: translateY(38rem) rotate(var(--confetti-spin)); } }
|
||||
@keyframes connectionsResultsIn { from { opacity: 0; transform: translateY(22px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
.animate-connections-shake { animation: connectionsShake .42s ease-in-out; }
|
||||
.animate-connections-results-in { animation: connectionsResultsIn .52s var(--ease-spring) both; }
|
||||
|
||||
/* Crossword turns the full arcade shell into a warm, ink-and-paper newsroom. */
|
||||
body:has(.crossword-world) {
|
||||
--theme-bg-base: #20170f;
|
||||
--theme-bg-surface: #f6ecd2;
|
||||
--theme-bg-surface-alt: #ead9b5;
|
||||
--theme-bg-callout: #dfc795;
|
||||
--theme-primary: #9a5a24;
|
||||
--theme-primary-hover: #7b431b;
|
||||
--theme-text-heading: #2d2117;
|
||||
--theme-text-body: #493725;
|
||||
--theme-text-secondary: #68523a;
|
||||
--theme-text-muted: #8a7052;
|
||||
--theme-border: #a9865d;
|
||||
--theme-border-light: #cfb88e;
|
||||
background:
|
||||
radial-gradient(circle at 18% 8%, rgba(230, 181, 88, .18), transparent 28rem),
|
||||
linear-gradient(135deg, #1d140d, #3c2818 55%, #21160e);
|
||||
}
|
||||
body:has(.crossword-world)::before {
|
||||
opacity: .16;
|
||||
background-image: repeating-linear-gradient(0deg, transparent 0 3px, rgba(255,236,195,.16) 4px);
|
||||
}
|
||||
body:has(.crossword-world) .app-sidebar,
|
||||
body:has(.crossword-world) .app-mobile-header {
|
||||
--theme-bg-surface-alt: #3a2819;
|
||||
--theme-bg-callout: #e4cc98;
|
||||
--theme-primary: #8a4f20;
|
||||
--theme-text-heading: #fff3d8;
|
||||
--theme-text-secondary: #d7c3a2;
|
||||
--theme-text-muted: #aa9170;
|
||||
--theme-border-light: #5b4028;
|
||||
color: #f8ead0;
|
||||
border-color: rgba(224, 190, 126, .22);
|
||||
background: rgba(35, 23, 14, .94);
|
||||
}
|
||||
|
||||
.crossword-world {
|
||||
position: relative;
|
||||
color: var(--theme-text-body);
|
||||
}
|
||||
.crossword-hub.crossword-world,
|
||||
.crossword-stage.crossword-world { overflow: visible; }
|
||||
.crossword-hero,
|
||||
.crossword-setup,
|
||||
.crossword-active-clue,
|
||||
.crossword-clues,
|
||||
.crossword-import,
|
||||
.crossword-paper {
|
||||
border: 1px solid rgba(100, 68, 35, .24);
|
||||
background:
|
||||
linear-gradient(rgba(255,255,255,.18), rgba(255,255,255,0)),
|
||||
repeating-linear-gradient(0deg, rgba(83,54,24,.025) 0 1px, transparent 1px 4px),
|
||||
#f4e7c9;
|
||||
box-shadow: inset 0 1px rgba(255,255,255,.7), 0 18px 48px rgba(15,9,4,.25);
|
||||
}
|
||||
.crossword-hero { position: relative; overflow: hidden; }
|
||||
.crossword-hero > * { position: relative; z-index: 1; }
|
||||
.crossword-hero::after {
|
||||
content: "DAILY STUDY";
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: .35rem;
|
||||
color: rgba(77,51,28,.07);
|
||||
font-family: Georgia, serif;
|
||||
font-size: clamp(2.75rem, 5vw, 5rem);
|
||||
font-weight: 900;
|
||||
letter-spacing: -.06em;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.crossword-kicker,
|
||||
.crossword-section-label {
|
||||
color: #925623;
|
||||
font-size: .68rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: .18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.crossword-section-label { display: block; margin-bottom: .7rem; color: var(--theme-text-muted); }
|
||||
.crossword-primary {
|
||||
color: #fff8e7;
|
||||
background: linear-gradient(135deg, #a86429, #744018);
|
||||
box-shadow: inset 0 1px rgba(255,255,255,.24), 0 7px 0 #4a2912, 0 12px 24px rgba(49,27,12,.22);
|
||||
transition: transform .16s var(--ease-spring), filter .16s ease;
|
||||
}
|
||||
.crossword-primary:hover { transform: translateY(-2px); filter: brightness(1.08); }
|
||||
.crossword-primary:active { transform: translateY(4px); box-shadow: inset 0 1px rgba(255,255,255,.2), 0 3px 0 #4a2912; }
|
||||
.crossword-pack {
|
||||
border-color: rgba(111,76,43,.25);
|
||||
background: rgba(244,231,201,.92);
|
||||
box-shadow: 0 10px 26px rgba(20,12,6,.16), inset 0 1px rgba(255,255,255,.65);
|
||||
transition: transform .18s ease, border-color .18s ease;
|
||||
}
|
||||
.crossword-pack:hover { transform: translateY(-2px); border-color: #a86429; }
|
||||
.crossword-pack.is-active { border-color: #8d511f; box-shadow: 0 0 0 2px rgba(168,100,41,.2), 0 14px 30px rgba(20,12,6,.2); }
|
||||
.crossword-paper > div i { aspect-ratio: 1; border: 1px solid rgba(45,33,23,.3); }
|
||||
.crossword-size {
|
||||
color: var(--theme-text-secondary);
|
||||
border: 1px solid var(--theme-border-light);
|
||||
background: rgba(255,250,235,.48);
|
||||
}
|
||||
.crossword-size.is-active { color: #2d2117; border-color: #955720; background: #e4c993; box-shadow: inset 0 0 0 2px rgba(255,255,255,.35); }
|
||||
.crossword-board-preview { overflow: hidden; border: 1px solid #c9aa78; border-radius: .9rem; background: rgba(255,250,235,.48); }
|
||||
.crossword-preview-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .65rem .8rem; border-bottom: 1px solid #d5bd93; color: #5d4630; }
|
||||
.crossword-preview-heading span { font-size: .68rem; font-weight: 900; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.crossword-preview-heading small { color: #8a7052; font-size: .68rem; }
|
||||
.crossword-preview-canvas { display: grid; min-height: 9rem; place-items: center; overflow: hidden; padding: .75rem; background: #322116; }
|
||||
.crossword-preview-grid { display: grid; filter: drop-shadow(0 6px 9px rgba(0,0,0,.35)); }
|
||||
.crossword-preview-grid i { border: .5px solid #9e8058; background: #fff6de; }
|
||||
.crossword-preview-loading,.crossword-preview-canvas > p { color: #d7c3a2; font-size: .75rem; font-weight: 800; }
|
||||
.crossword-option { display: flex; align-items: flex-start; gap: .7rem; padding: .75rem; border-radius: .75rem; background: rgba(224,202,157,.38); }
|
||||
.crossword-option input { margin-top: .2rem; accent-color: #925623; }
|
||||
.crossword-option strong,.crossword-option small { display: block; }
|
||||
.crossword-option small { margin-top: .12rem; color: var(--theme-text-muted); }
|
||||
.crossword-active-clue { display: grid; gap: .2rem; }
|
||||
.crossword-active-clue span { color: #925623; font-size: .68rem; font-weight: 900; letter-spacing: .15em; text-transform: uppercase; }
|
||||
.crossword-active-clue strong { color: #2d2117; font-family: Georgia, serif; font-size: 1.05rem; }
|
||||
.crossword-active-clue small { color: var(--theme-text-muted); }
|
||||
.crossword-toolbar { color: #f4e7c9; background: rgba(35,23,14,.9); box-shadow: 0 8px 24px rgba(13,8,4,.24); }
|
||||
.crossword-toolbar > div { display: flex; align-items: center; gap: .35rem; }
|
||||
.crossword-toolbar button { display: grid; width: 2rem; height: 2rem; place-items: center; border: 1px solid rgba(245,222,179,.25); border-radius: .45rem; font-weight: 900; }
|
||||
.crossword-toolbar span { min-width: 3rem; text-align: center; font-size: .75rem; font-weight: 800; }
|
||||
.crossword-board-viewport {
|
||||
max-height: min(68vh, 46rem);
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
overscroll-behavior: contain;
|
||||
background: #2a1d13;
|
||||
box-shadow: inset 0 0 0 5px #4c321e, inset 0 0 28px rgba(0,0,0,.7), 0 16px 38px rgba(13,8,4,.3);
|
||||
}
|
||||
.crossword-board { display: grid; width: max-content; min-width: 100%; min-height: 20rem; place-content: center; }
|
||||
.crossword-cell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: var(--crossword-cell);
|
||||
height: var(--crossword-cell);
|
||||
place-items: center;
|
||||
color: #25190f;
|
||||
border: 1px solid #6f5335;
|
||||
background: #fff8e6;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
transition: background .12s ease, box-shadow .12s ease;
|
||||
}
|
||||
.crossword-cell small { position: absolute; left: 2px; top: 0; font-size: max(7px, calc(var(--crossword-cell) * .22)); line-height: 1; }
|
||||
.crossword-cell strong { font-size: max(12px, calc(var(--crossword-cell) * .56)); }
|
||||
.crossword-cell.is-word { background: #f2d99f; }
|
||||
.crossword-cell.is-active { z-index: 2; background: #ffe27e; box-shadow: inset 0 0 0 3px #925623; }
|
||||
.crossword-cell.is-wrong::after { content: ""; position: absolute; right: 3px; bottom: 3px; width: 7px; height: 7px; border: 2px solid #a62e25; border-radius: 50%; }
|
||||
.crossword-cell.is-revealed strong { color: #8a5a24; text-decoration: underline dotted; }
|
||||
.crossword-secondary {
|
||||
min-height: 2.75rem;
|
||||
padding: .6rem .9rem;
|
||||
border: 1px solid #9f7a50;
|
||||
border-radius: .7rem;
|
||||
color: #392718;
|
||||
background: #ead6ae;
|
||||
font-size: .78rem;
|
||||
font-weight: 850;
|
||||
box-shadow: 0 3px 0 #aa895f;
|
||||
}
|
||||
.crossword-secondary:hover { background: #f5e5c4; }
|
||||
.crossword-clues { max-height: 72vh; overflow: hidden; }
|
||||
.crossword-clues > div button { min-height: 2.5rem; border-bottom: 2px solid transparent; color: var(--theme-text-muted); font-size: .75rem; font-weight: 900; text-transform: uppercase; }
|
||||
.crossword-clues > div button.is-active { color: #7d461d; border-color: #9a5a24; }
|
||||
.crossword-clues ol { max-height: calc(72vh - 5rem); overflow-y: auto; padding-right: .25rem; }
|
||||
.crossword-clues li button { display: grid; width: 100%; grid-template-columns: 2rem 1fr; gap: .4rem; padding: .55rem; border-radius: .5rem; text-align: left; color: #4b3826; }
|
||||
.crossword-clues li button:hover,.crossword-clues li button.is-active { background: #dec38c; }
|
||||
.crossword-clues li b { color: #8a4f20; }
|
||||
.crossword-mobile-input { position: fixed; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.crossword-results-hero { color: #f8ead0; background: linear-gradient(135deg, #3b2819, #6d421f); box-shadow: 0 18px 45px rgba(15,9,4,.3); }
|
||||
.crossword-results-hero > div > div { background: rgba(255,244,220,.1); }
|
||||
.crossword-results-hero small,.crossword-results-hero strong { display: block; }
|
||||
.crossword-results-hero small { color: rgba(255,244,220,.6); font-size: .65rem; font-weight: 900; text-transform: uppercase; }
|
||||
.crossword-results-hero strong { margin-top: .2rem; font-size: 1.25rem; }
|
||||
.crossword-final-board { border: 1px solid #c8aa79; color: #392718; background: #f4e7c9; box-shadow: 0 12px 32px rgba(20,12,6,.18); }
|
||||
.crossword-final-legend { display: flex; flex-wrap: wrap; gap: .8rem; color: #6a5139; font-size: .72rem; font-weight: 800; }
|
||||
.crossword-final-legend span { display: inline-flex; align-items: center; gap: .35rem; }
|
||||
.crossword-final-legend i { width: .8rem; height: .8rem; border: 1px solid currentColor; border-radius: .15rem; }
|
||||
.crossword-final-legend i.is-correct { color: #356b3b; background: #bfe0b9; }
|
||||
.crossword-final-legend i.is-assisted { color: #8a631c; background: #f2d47e; }
|
||||
.crossword-final-legend i.is-incorrect { color: #923a30; background: #efb9af; }
|
||||
.crossword-final-viewport { overflow: auto; padding: 1rem; border-radius: 1rem; background: #2a1d13; box-shadow: inset 0 0 22px rgba(0,0,0,.55); }
|
||||
.crossword-final-grid { display: grid; width: max-content; min-width: 100%; place-content: center; }
|
||||
.crossword-final-cell { position: relative; display: grid; place-items: center; color: #25190f; border: 1px solid #62482f; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.crossword-final-cell.is-correct { background: #c7e6bf; box-shadow: inset 0 0 0 1px rgba(47,108,58,.35); }
|
||||
.crossword-final-cell.is-assisted { background: #f2d47e; box-shadow: inset 0 0 0 2px rgba(138,99,28,.42); }
|
||||
.crossword-final-cell.is-incorrect { background: #efbbb1; box-shadow: inset 0 0 0 2px rgba(145,47,39,.45); }
|
||||
.crossword-final-cell small { position: absolute; left: 2px; top: 1px; font-size: 7px; line-height: 1; }
|
||||
.crossword-final-cell strong { font-size: clamp(10px, 2vw, 15px); }
|
||||
.crossword-review { border: 1px solid #c8aa79; color: #392718; background: #f4e7c9; box-shadow: 0 8px 22px rgba(20,12,6,.15); }
|
||||
.crossword-review.is-incorrect { border-left: 6px solid #a63a2e; }
|
||||
.crossword-review.is-correct { border-left: 6px solid #4f7a4f; }
|
||||
.crossword-review.is-assisted { border-left: 6px solid #b58627; }
|
||||
.crossword-review.is-omitted { opacity: .75; border-left: 6px solid #8c765a; }
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.crossword-board-viewport { margin-inline: -.25rem; padding: .6rem; }
|
||||
.crossword-board { place-content: start; min-height: 24rem; }
|
||||
.crossword-clues { max-height: 28rem; }
|
||||
.crossword-hero::after { top: .75rem; right: .75rem; font-size: 2.75rem; }
|
||||
.crossword-final-viewport { margin-inline: -.25rem; padding: .65rem; }
|
||||
}
|
||||
|
||||
.markdown-content h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: .5rem; color: var(--color-text-heading); }
|
||||
.markdown-content h2 { font-size: 1.25rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }
|
||||
.markdown-content h3 { font-size: 1.125rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }
|
||||
|
|
|
|||
|
|
@ -5,15 +5,14 @@ import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
|||
|
||||
type LoginStage = "login" | "token" | "password" | "success";
|
||||
|
||||
async function postJson<T extends object = Record<string, never>>(path: string, body?: object): Promise<T> {
|
||||
async function postJson(path: string, body?: object) {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
const data = await response.json() as T & { error?: string };
|
||||
const data: { error?: string } = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "Request failed");
|
||||
return data;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
|
|
@ -24,26 +23,12 @@ export default function LoginPage() {
|
|||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [setupRequired, setSetupRequired] = useState<boolean | null>(null);
|
||||
const [setupAllowed, setSetupAllowed] = useState(false);
|
||||
const [statusError, setStatusError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetch("/api/auth/setup-status", { signal: controller.signal })
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error("Setup status could not be loaded.");
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setSetupRequired(Boolean(data.setupRequired));
|
||||
setSetupAllowed(Boolean(data.setupAllowed));
|
||||
})
|
||||
.catch((caught) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setStatusError(caught instanceof Error ? caught.message : "Setup status could not be loaded.");
|
||||
setSetupRequired(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
fetch("/api/auth/setup-status")
|
||||
.then((response) => response.json())
|
||||
.then((data) => setSetupRequired(data.setupRequired))
|
||||
.catch(() => setSetupRequired(false));
|
||||
}, []);
|
||||
|
||||
async function run(action: () => Promise<void>) {
|
||||
|
|
@ -68,8 +53,8 @@ export default function LoginPage() {
|
|||
|
||||
function beginReset() {
|
||||
void run(async () => {
|
||||
const response = await postJson<{ token: string }>("/api/auth/password-reset/request");
|
||||
setToken(response.token);
|
||||
await postJson("/api/auth/password-reset/request");
|
||||
setToken("");
|
||||
setStage("token");
|
||||
});
|
||||
}
|
||||
|
|
@ -154,12 +139,6 @@ export default function LoginPage() {
|
|||
<div className="rounded-[2rem] border border-border-light bg-bg-surface/90 p-6 shadow-[var(--shadow-modal)] backdrop-blur sm:p-8">
|
||||
{stage === "login" && (
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
{setupRequired && !setupAllowed && (
|
||||
<div role="alert" className="rounded-lg bg-error-bg px-3 py-2.5 text-sm text-error">
|
||||
Initial setup is disabled. Provision ADMIN_PASSWORD_HASH or temporarily enable ALLOW_INITIAL_SETUP on the local server.
|
||||
</div>
|
||||
)}
|
||||
{statusError && <ErrorMessage message={statusError} />}
|
||||
<PasswordField
|
||||
id="password"
|
||||
label="Password"
|
||||
|
|
@ -169,7 +148,7 @@ export default function LoginPage() {
|
|||
autoFocus
|
||||
/>
|
||||
<ErrorMessage message={error} />
|
||||
<PrimaryButton loading={loading} disabled={password.length < 8 || (setupRequired && !setupAllowed)}>
|
||||
<PrimaryButton loading={loading} disabled={password.length < 8}>
|
||||
{setupRequired ? "Save Password & Login" : "Sign In"}
|
||||
</PrimaryButton>
|
||||
{!setupRequired && (
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import Link from "next/link";
|
|||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { SharedGroupData } from "@/types/study";
|
||||
|
||||
export function SharedGroupViewer({ data, token }: { data: SharedGroupData; token: string }) {
|
||||
export function SharedGroupViewer({ data }: { data: SharedGroupData }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
|
|
@ -16,34 +16,29 @@ export function SharedGroupViewer({ data, token }: { data: SharedGroupData; toke
|
|||
const [progressMap, setProgressMap] = useState<Record<string, { currentIndex: number, total: number }>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
const newProgress: Record<string, { currentIndex: number, total: number }> = {};
|
||||
items.forEach((item) => {
|
||||
const key = data.type === "DECK"
|
||||
? `flashcard_progress_${token}_${item.id}`
|
||||
: `quiz_progress_${token}_${item.id}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
const total = data.type === "DECK" ? (parsed.order?.length || item.cards?.length || 0) : (parsed.order?.length || item.questions?.length || 0);
|
||||
newProgress[item.id] = {
|
||||
currentIndex: parsed.currentIndex || 0,
|
||||
total,
|
||||
};
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
setProgressMap(newProgress);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [items, data.type, token]);
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const newProgress: Record<string, { currentIndex: number, total: number }> = {};
|
||||
items.forEach((item) => {
|
||||
const key = data.type === "DECK" ? `flashcard_progress_${item.id}` : `quiz_progress_${item.id}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
const total = data.type === "DECK" ? (parsed.order?.length || item.cards?.length || 0) : (parsed.order?.length || item.questions?.length || 0);
|
||||
newProgress[item.id] = {
|
||||
currentIndex: parsed.currentIndex || 0,
|
||||
total,
|
||||
};
|
||||
} catch(e) {}
|
||||
}
|
||||
});
|
||||
setProgressMap(newProgress);
|
||||
}, [items, data.type]);
|
||||
|
||||
const handleRestart = (e: React.MouseEvent, itemId: string) => {
|
||||
e.preventDefault();
|
||||
const key = data.type === "DECK"
|
||||
? `flashcard_progress_${token}_${itemId}`
|
||||
: `quiz_progress_${token}_${itemId}`;
|
||||
const key = data.type === "DECK" ? `flashcard_progress_${itemId}` : `quiz_progress_${itemId}`;
|
||||
localStorage.removeItem(key);
|
||||
setProgressMap(prev => {
|
||||
const next = { ...prev };
|
||||
|
|
|
|||
|
|
@ -12,29 +12,24 @@ interface SharedViewerProps {
|
|||
type: "flashcards" | "quizzes";
|
||||
data: SharedStudyItem;
|
||||
groupMode?: boolean;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export function SharedViewer({ type, data, groupMode = false, token }: SharedViewerProps) {
|
||||
export function SharedViewer({ type, data, groupMode = false }: SharedViewerProps) {
|
||||
const pathname = usePathname();
|
||||
const [restartKey, setRestartKey] = useState(0);
|
||||
const [view, setView] = useState<"study" | "list">("study");
|
||||
const [hasSavedSession, setHasSavedSession] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
const key = type === "flashcards"
|
||||
? `flashcard_progress_${token}_${data.id}`
|
||||
: `quiz_progress_${token}_${data.id}`;
|
||||
setHasSavedSession(Boolean(localStorage.getItem(key)));
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [type, data.id, restartKey, token]);
|
||||
// Check if there's a saved session for this item
|
||||
const key = type === "flashcards" ? `flashcard_progress_${data.id}` : `quiz_progress_${data.id}`;
|
||||
if (localStorage.getItem(key)) {
|
||||
setHasSavedSession(true);
|
||||
}
|
||||
}, [type, data.id, restartKey]);
|
||||
|
||||
function handleRestart() {
|
||||
const key = type === "flashcards"
|
||||
? `flashcard_progress_${token}_${data.id}`
|
||||
: `quiz_progress_${token}_${data.id}`;
|
||||
const key = type === "flashcards" ? `flashcard_progress_${data.id}` : `quiz_progress_${data.id}`;
|
||||
localStorage.removeItem(key);
|
||||
setHasSavedSession(false);
|
||||
setRestartKey(k => k + 1);
|
||||
|
|
@ -146,7 +141,6 @@ export function SharedViewer({ type, data, groupMode = false, token }: SharedVie
|
|||
cards={data.cards ?? []}
|
||||
deckId={data.id}
|
||||
isShared={true}
|
||||
storageNamespace={token}
|
||||
/>
|
||||
) : (
|
||||
<CardList cards={data.cards ?? []} />
|
||||
|
|
@ -162,7 +156,6 @@ export function SharedViewer({ type, data, groupMode = false, token }: SharedVie
|
|||
}}
|
||||
retakeIds={null}
|
||||
isShared={true}
|
||||
storageNamespace={token}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export async function generateMetadata(
|
|||
}
|
||||
|
||||
const link = await getShareLinkMeta(token);
|
||||
return buildShareMetadata(link, { classSlug, itemId, pathType: type });
|
||||
return buildShareMetadata(link, { classSlug, itemId });
|
||||
}
|
||||
|
||||
export default async function SharedPage(props: SharedPageProps & { searchParams: Promise<{ itemId?: string }> }) {
|
||||
|
|
@ -71,29 +71,18 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
|||
let targetType: SharedContentType = type === "flashcards" ? "flashcards" : "quizzes";
|
||||
|
||||
if (type === "groups" && link.group) {
|
||||
const groupClassSlug = link.group.class.slug;
|
||||
const validDecks = link.group.decks.filter(
|
||||
(deck) => link.group?.type === "DECK" && deck.class.slug === groupClassSlug
|
||||
);
|
||||
const validQuizSets = link.group.quizSets.filter(
|
||||
(quiz) => link.group?.type === "QUIZ" && quiz.class.slug === groupClassSlug
|
||||
);
|
||||
if (itemId) {
|
||||
// Find the specific item inside the group
|
||||
if (link.group.type === "DECK") {
|
||||
targetData = validDecks.find(d => d.id === itemId) ?? null;
|
||||
targetData = link.group.decks.find(d => d.id === itemId) ?? null;
|
||||
targetType = "flashcards";
|
||||
} else {
|
||||
targetData = validQuizSets.find(q => q.id === itemId) ?? null;
|
||||
targetData = link.group.quizSets.find(q => q.id === itemId) ?? null;
|
||||
targetType = "quizzes";
|
||||
}
|
||||
if (!targetData) notFound();
|
||||
} else {
|
||||
targetData = {
|
||||
...link.group,
|
||||
decks: validDecks,
|
||||
quizSets: validQuizSets,
|
||||
};
|
||||
targetData = link.group;
|
||||
}
|
||||
} else {
|
||||
targetData = type === "flashcards" ? link.deck : link.quizSet;
|
||||
|
|
@ -137,13 +126,12 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
|||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-5xl px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
|
||||
{type === "groups" && !itemId ? (
|
||||
<SharedGroupViewer data={targetData as SharedGroupData} token={token} />
|
||||
<SharedGroupViewer data={targetData as SharedGroupData} />
|
||||
) : (
|
||||
<SharedViewer
|
||||
type={targetType}
|
||||
data={targetData as SharedStudyItem}
|
||||
groupMode={type === "groups"}
|
||||
token={token}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ interface DailyActivity {
|
|||
date: string;
|
||||
flashcards: number;
|
||||
questions: number;
|
||||
arcade: number;
|
||||
total: number;
|
||||
level: 0 | 1 | 2 | 3 | 4;
|
||||
}
|
||||
|
|
@ -153,7 +154,7 @@ function DayTooltip({ day }: { day: DailyActivity }) {
|
|||
return (
|
||||
<div role="tooltip" className="rounded-lg border border-border bg-bg-surface px-3 py-2 text-right text-xs shadow-xl dark:border-white/15 dark:bg-[#0d1422]">
|
||||
<div className="font-bold text-text-heading dark:text-white">{formatted} · {day.total} total</div>
|
||||
<div className="mt-0.5 text-text-secondary dark:text-white/60">{day.flashcards} flashcards · {day.questions} questions</div>
|
||||
<div className="mt-0.5 text-text-secondary dark:text-white/60">{day.flashcards} flashcards · {day.questions} questions · {day.arcade} arcade groups</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -184,5 +185,5 @@ function getMonthLabel(week: DailyActivity[], previousWeek?: DailyActivity[]) {
|
|||
}
|
||||
|
||||
function getDayAriaLabel(day: DailyActivity) {
|
||||
return `${day.date}: ${day.flashcards} flashcards, ${day.questions} questions, ${day.total} total activities`;
|
||||
return `${day.date}: ${day.flashcards} flashcards, ${day.questions} questions, and ${day.arcade} arcade groups, ${day.total} total activities`;
|
||||
}
|
||||
|
|
|
|||
38
src/components/arcade/ArcadeGameShell.tsx
Normal file
38
src/components/arcade/ArcadeGameShell.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function ArcadeGameShell({
|
||||
title,
|
||||
exitHref,
|
||||
elapsedSeconds,
|
||||
complete,
|
||||
worldClassName = "connections-world connections-stage",
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
exitHref: string;
|
||||
elapsedSeconds: number;
|
||||
complete: boolean;
|
||||
worldClassName?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
function exitGame() {
|
||||
if (!complete && !window.confirm("Leave this round? Your progress will not be saved.")) return;
|
||||
router.push(exitHref);
|
||||
}
|
||||
const minutes = Math.floor(elapsedSeconds / 60);
|
||||
const seconds = String(elapsedSeconds % 60).padStart(2, "0");
|
||||
return (
|
||||
<div className={`${worldClassName} mx-auto w-full max-w-6xl p-1 pb-16 sm:p-3`}>
|
||||
<header className="arcade-game-topbar connections-topbar mb-5 flex items-center justify-between gap-4 rounded-2xl border border-border-light bg-bg-surface/85 px-4 py-3 shadow-sm">
|
||||
<button onClick={exitGame} className="min-h-10 rounded-xl px-3 text-sm font-bold text-text-muted hover:bg-bg-surface-alt hover:text-text-heading">← Exit</button>
|
||||
<h1 className="truncate text-center text-lg font-extrabold text-text-heading">{title}</h1>
|
||||
<div className="min-w-16 text-right font-mono text-sm font-bold text-text-muted" aria-label={`${elapsedSeconds} seconds elapsed`}>{minutes}:{seconds}</div>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
src/components/arcade/ArcadeImportModal.tsx
Normal file
127
src/components/arcade/ArcadeImportModal.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { GenerateTab } from "@/components/import/GenerateTab";
|
||||
import type { ArcadeGameKey, ArcadeImportBatchPreview } from "@/types/arcade";
|
||||
|
||||
export function ArcadeImportModal({
|
||||
classId,
|
||||
gameType,
|
||||
onClose,
|
||||
onImported,
|
||||
}: {
|
||||
classId: string;
|
||||
gameType: ArcadeGameKey;
|
||||
onClose: () => void;
|
||||
onImported: () => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"generate" | "import">("import");
|
||||
const [rawJson, setRawJson] = useState("");
|
||||
const [names, setNames] = useState<string[]>([]);
|
||||
const [preview, setPreview] = useState<ArcadeImportBatchPreview | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [details, setDetails] = useState<string[]>([]);
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function previewImport() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setDetails([]);
|
||||
setPreview(null);
|
||||
try {
|
||||
const response = await fetch("/api/arcade/import/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ gameType, rawJson }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
setError(data.error ?? "Import preview failed");
|
||||
setDetails(data.details ?? []);
|
||||
return;
|
||||
}
|
||||
setPreview(data);
|
||||
setNames(data.packs.map((pack: { name: string }) => pack.name));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveImport() {
|
||||
if (!preview || names.some((name) => !name.trim())) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/arcade/packs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
classId,
|
||||
gameType,
|
||||
rawJson,
|
||||
...(gameType === "connections" ? { names: names.map((name) => name.trim()) } : { name: names[0]?.trim() }),
|
||||
warningsAcknowledged: acknowledged,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
setError(data.error ?? "Import failed");
|
||||
setDetails(data.details ?? []);
|
||||
return;
|
||||
}
|
||||
onImported();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center sm:p-4">
|
||||
<button className="absolute inset-0 bg-black/45 backdrop-blur-sm" onClick={onClose} aria-label="Close import dialog" />
|
||||
<section role="dialog" aria-modal="true" aria-labelledby="arcade-import-title" className={`${gameType === "connections" ? "connections-world connections-import" : "crossword-world crossword-import"} relative flex max-h-[92vh] w-full max-w-2xl flex-col rounded-t-3xl border border-border-light bg-bg-surface shadow-[var(--shadow-modal)] sm:rounded-3xl`}>
|
||||
<div className="flex items-center justify-between px-6 pt-5">
|
||||
<h2 id="arcade-import-title" className="editorial-title text-2xl text-text-heading">Import {gameType === "connections" ? "Connections" : "Crossword"} pack</h2>
|
||||
<button onClick={onClose} className="grid h-10 w-10 place-items-center rounded-xl text-xl text-text-muted hover:bg-bg-surface-alt" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div className="flex gap-1 border-b border-border-light px-6 pt-4">
|
||||
{(["generate", "import"] as const).map((item) => (
|
||||
<button key={item} onClick={() => setTab(item)} className={`border-b-2 px-4 py-3 text-sm font-bold capitalize ${tab === item ? "border-primary text-primary" : "border-transparent text-text-muted"}`}>{item}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="overflow-y-auto p-6">
|
||||
{tab === "generate" ? <GenerateTab importType={gameType} /> : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-text-secondary">{gameType === "connections" ? "Paste one pack object or an array of up to ten packs." : "Paste one Crossword pack containing exactly 80 entries."} Every board is validated before anything is saved.</p>
|
||||
<textarea value={rawJson} onChange={(event) => { setRawJson(event.target.value); setPreview(null); setError(""); }} rows={11} placeholder={`Paste ${gameType === "connections" ? "Connections" : "Crossword"} JSON here…`} className="w-full resize-y rounded-xl border border-border bg-bg-surface-alt/60 px-4 py-3 font-mono text-sm text-text-heading focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" />
|
||||
{!preview && <button onClick={previewImport} disabled={!rawJson.trim() || busy} className="w-full min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white disabled:opacity-45">{busy ? "Checking…" : "Preview pack(s)"}</button>}
|
||||
{error && <div role="alert" className="rounded-xl border border-error/20 bg-error-bg p-4 text-sm text-error"><p className="font-bold">{error}</p>{details.length > 0 && <ul className="mt-2 list-disc space-y-1 pl-5">{details.map((detail) => <li key={detail}>{detail}</li>)}</ul>}</div>}
|
||||
{preview && (
|
||||
<div className="space-y-4 rounded-2xl border border-primary/15 bg-bg-callout p-4">
|
||||
<div className="flex items-center justify-between"><p className="font-extrabold text-text-heading">{preview.count} {preview.count === 1 ? "pack" : "packs"} ready</p>{preview.wasRepaired && <span className="text-xs font-bold text-primary">JSON syntax repaired</span>}</div>
|
||||
<div className="space-y-3">
|
||||
{preview.packs.map((pack, index) => (
|
||||
<article key={`${pack.name}-${index}`} className="rounded-xl border border-border-light bg-bg-surface p-3">
|
||||
<label className="mb-1 block text-xs font-bold uppercase tracking-wide text-text-muted">Pack {index + 1} name</label>
|
||||
<input value={names[index] ?? ""} onChange={(event) => setNames((current) => current.map((name, nameIndex) => nameIndex === index ? event.target.value : name))} className="w-full rounded-lg border border-border bg-bg-surface-alt/50 px-3 py-2 font-bold text-text-heading" />
|
||||
{gameType === "connections" ? <>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">{pack.categories.map((category) => <span key={category} className="rounded-full bg-bg-surface-alt px-2 py-1 text-[11px] font-bold text-text-secondary">{category}</span>)}</div>
|
||||
<p className="mt-2 text-xs text-text-muted">16 tiles · 4 groups</p>
|
||||
</> : <>
|
||||
<p className="mt-2 text-xs text-text-muted">80 entries · four board sizes</p>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-xs text-text-secondary">{pack.layoutPreviews?.map((layout) => <span key={layout.size} className="rounded-lg bg-bg-surface-alt px-2 py-1.5 capitalize">{layout.size}: {layout.placedCount}/{layout.targetCount}</span>)}</div>
|
||||
</>}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{preview.packs.some((pack) => pack.warnings.length > 0) && <div className="rounded-xl border border-amber-400/30 bg-amber-400/10 p-3 text-sm text-text-secondary"><p className="font-bold text-text-heading">Review warnings</p><ul className="mt-1 list-disc space-y-1 pl-5">{preview.packs.flatMap((pack, index) => pack.warnings.map((warning) => `Pack ${index + 1}: ${warning}`)).map((warning) => <li key={warning}>{warning}</li>)}</ul><label className="mt-3 flex items-start gap-2"><input type="checkbox" checked={acknowledged} onChange={(event) => setAcknowledged(event.target.checked)} className="mt-1" /><span>I reviewed these warnings and want to import the packs.</span></label></div>}
|
||||
<button onClick={saveImport} disabled={busy || names.some((name) => !name.trim()) || (preview.packs.some((pack) => pack.warnings.length > 0) && !acknowledged)} className="w-full min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white disabled:opacity-45">{busy ? "Importing…" : `Import ${preview.count} ${preview.count === 1 ? "pack" : "packs"}`}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
237
src/components/arcade/ConnectionsGame.tsx
Normal file
237
src/components/arcade/ConnectionsGame.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { ArcadeGameShell } from "@/components/arcade/ArcadeGameShell";
|
||||
import { deterministicShuffle, replayConnectionsAttempt } from "@/lib/arcade/connectionsEngine";
|
||||
import type {
|
||||
ArcadeRoundResult,
|
||||
ArcadeSessionSettings,
|
||||
ConnectionsSubmission,
|
||||
NormalizedConnectionsPack,
|
||||
} from "@/types/arcade";
|
||||
|
||||
export function ConnectionsGame({
|
||||
seed,
|
||||
packId,
|
||||
packName,
|
||||
classSlug,
|
||||
pack,
|
||||
settings,
|
||||
}: {
|
||||
seed: string;
|
||||
packId: string;
|
||||
packName: string;
|
||||
classSlug: string;
|
||||
pack: NormalizedConnectionsPack;
|
||||
settings: ArcadeSessionSettings;
|
||||
}) {
|
||||
const allItems = useMemo(() => pack.content.flatMap((group) => group.items), [pack]);
|
||||
const itemById = useMemo(() => new Map(allItems.map((item) => [item.id, item])), [allItems]);
|
||||
const [tileOrder, setTileOrder] = useState(() => deterministicShuffle(allItems.map((item) => item.id), seed));
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [submissions, setSubmissions] = useState<ConnectionsSubmission[]>([]);
|
||||
const [feedback, setFeedback] = useState("Select four related tiles.");
|
||||
const [result, setResult] = useState<ArcadeRoundResult | null>(null);
|
||||
const [finishing, setFinishing] = useState<ArcadeRoundResult | null>(null);
|
||||
const [feedbackKind, setFeedbackKind] = useState<"idle" | "correct" | "wrong">("idle");
|
||||
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
const shuffleCount = useRef(0);
|
||||
const startedAt = useRef(0);
|
||||
const replay = useMemo(
|
||||
() => replayConnectionsAttempt(pack, submissions, settings, elapsedSeconds),
|
||||
[elapsedSeconds, pack, settings, submissions]
|
||||
);
|
||||
const solvedGroupIds = useMemo(
|
||||
() => new Set(replay.result.groups.filter((group) => group.solved).map((group) => group.groupId)),
|
||||
[replay.result.groups]
|
||||
);
|
||||
const solvedItemIds = useMemo(
|
||||
() => new Set(pack.content.filter((group) => solvedGroupIds.has(group.id)).flatMap((group) => group.items.map((item) => item.id))),
|
||||
[pack, solvedGroupIds]
|
||||
);
|
||||
const unresolvedOrder = tileOrder.filter((id) => !solvedItemIds.has(id));
|
||||
|
||||
useEffect(() => {
|
||||
if (startedAt.current === 0) startedAt.current = Date.now();
|
||||
if (result) return;
|
||||
const interval = window.setInterval(() => setElapsedSeconds(Math.floor((Date.now() - startedAt.current) / 1000)), 1000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [result]);
|
||||
|
||||
function toggleTile(id: string) {
|
||||
if (result || solvedItemIds.has(id)) return;
|
||||
setSelectedIds((current) => current.includes(id) ? current.filter((itemId) => itemId !== id) : current.length < 4 ? [...current, id] : current);
|
||||
}
|
||||
|
||||
async function persistAttempt(nextSubmissions: ConnectionsSubmission[], roundResult: ArcadeRoundResult) {
|
||||
setSaveState("saving");
|
||||
const response = await fetch(`/api/arcade/packs/${packId}/attempts`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mode: "CLASSIC",
|
||||
durationSeconds: roundResult.durationSeconds,
|
||||
seed,
|
||||
settings,
|
||||
submissions: nextSubmissions,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
setSaveState("error");
|
||||
return;
|
||||
}
|
||||
await response.json();
|
||||
setSaveState("saved");
|
||||
}
|
||||
|
||||
function submitSelection() {
|
||||
if (selectedIds.length !== 4 || result) return;
|
||||
const previousSolved = solvedGroupIds.size;
|
||||
const nextSubmissions = [...submissions, { itemIds: selectedIds, elapsedMs: Date.now() - startedAt.current }];
|
||||
const duration = Math.floor((Date.now() - startedAt.current) / 1000);
|
||||
const nextReplay = replayConnectionsAttempt(pack, nextSubmissions, settings, duration);
|
||||
setSubmissions(nextSubmissions);
|
||||
setSelectedIds([]);
|
||||
if (nextReplay.result.groups.filter((group) => group.solved).length > previousSolved) {
|
||||
setFeedback("Correct group found.");
|
||||
setFeedbackKind("correct");
|
||||
} else if (nextReplay.result.hintsUsed > replay.result.hintsUsed) {
|
||||
setFeedback("One away — three of those tiles belong together.");
|
||||
setFeedbackKind("wrong");
|
||||
} else {
|
||||
setFeedback("Not a group. Try another combination.");
|
||||
setFeedbackKind("wrong");
|
||||
}
|
||||
if (nextReplay.complete) {
|
||||
setElapsedSeconds(duration);
|
||||
setFinishing(nextReplay.result);
|
||||
window.setTimeout(() => {
|
||||
setResult(nextReplay.result);
|
||||
setFinishing(null);
|
||||
}, nextReplay.result.outcome === "WON" ? 1700 : 850);
|
||||
void persistAttempt(nextSubmissions, nextReplay.result);
|
||||
}
|
||||
}
|
||||
|
||||
function shuffleTiles() {
|
||||
shuffleCount.current += 1;
|
||||
const shuffled = deterministicShuffle(unresolvedOrder, `${seed}-shuffle-${shuffleCount.current}`);
|
||||
setTileOrder([...tileOrder.filter((id) => solvedItemIds.has(id)), ...shuffled]);
|
||||
setSelectedIds([]);
|
||||
setFeedback("Unsolved tiles shuffled.");
|
||||
setFeedbackKind("idle");
|
||||
}
|
||||
|
||||
function handleTileKey(event: React.KeyboardEvent<HTMLButtonElement>, id: string) {
|
||||
const moves: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -4, ArrowDown: 4 };
|
||||
const move = moves[event.key];
|
||||
if (!move) return;
|
||||
event.preventDefault();
|
||||
const index = unresolvedOrder.indexOf(id);
|
||||
const next = unresolvedOrder[(index + move + unresolvedOrder.length) % unresolvedOrder.length];
|
||||
document.querySelector<HTMLButtonElement>(`[data-arcade-tile="${next}"]`)?.focus();
|
||||
}
|
||||
|
||||
if (result) {
|
||||
return (
|
||||
<ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/connections`} elapsedSeconds={elapsedSeconds} complete>
|
||||
<ConnectionsResults result={result} itemById={itemById} saveState={saveState} onReplay={() => window.location.reload()} exitHref={`/${classSlug}/arcade/connections`} />
|
||||
</ArcadeGameShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (finishing) {
|
||||
return (
|
||||
<ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/connections`} elapsedSeconds={elapsedSeconds} complete>
|
||||
<CompletionTransition result={finishing} />
|
||||
</ArcadeGameShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/connections`} elapsedSeconds={elapsedSeconds} complete={false}>
|
||||
<div className="connections-status mb-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-border-light bg-bg-surface p-4">
|
||||
<div><p className="text-xs font-bold uppercase tracking-wide text-text-muted">Mistakes remaining</p><div className="mt-1 flex gap-1.5" aria-label={`${settings.allowedMistakes - replay.result.mistakes} mistakes remaining`}>{Array.from({ length: settings.allowedMistakes }, (_, index) => <span key={index} className={`h-3 w-3 rounded-full border ${index < settings.allowedMistakes - replay.result.mistakes ? "border-primary bg-primary" : "border-border bg-bg-surface-alt"}`} />)}</div></div>
|
||||
<p className="text-sm font-bold text-text-secondary" aria-live="polite">{feedback}</p>
|
||||
</div>
|
||||
|
||||
{pack.content.filter((group) => solvedGroupIds.has(group.id)).map((group, index) => (
|
||||
<div key={group.id} className={`connections-group connections-group-${index % 4} mb-2 rounded-2xl p-4 text-center`}>
|
||||
<h2 className="font-extrabold uppercase tracking-wide text-text-heading">{group.category}</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-text-secondary">{group.items.map((item) => item.text).join(" · ")}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div key={`board-${submissions.length}`} className={`connections-board grid grid-cols-4 gap-2 sm:gap-3 ${feedbackKind === "wrong" ? "animate-connections-shake" : ""}`} role="group" aria-label="Connections tiles">
|
||||
{unresolvedOrder.map((id) => {
|
||||
const item = itemById.get(id);
|
||||
if (!item) return null;
|
||||
const selected = selectedIds.includes(id);
|
||||
return <button key={id} data-arcade-tile={id} aria-pressed={selected} onKeyDown={(event) => handleTileKey(event, id)} onClick={() => toggleTile(id)} className={`connections-tile min-h-20 break-words rounded-xl px-1.5 py-2 text-[11px] font-extrabold leading-tight sm:min-h-24 sm:px-3 sm:text-sm ${selected ? "is-selected" : ""}`}>{item.text}</button>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 sm:flex sm:justify-center">
|
||||
<button onClick={shuffleTiles} className="connections-secondary-button min-h-11 rounded-xl px-5 text-sm font-bold">Shuffle</button>
|
||||
<button onClick={() => setSelectedIds([])} disabled={selectedIds.length === 0} className="connections-secondary-button min-h-11 rounded-xl px-5 text-sm font-bold disabled:opacity-40">Clear</button>
|
||||
<button onClick={submitSelection} disabled={selectedIds.length !== 4} className="connections-primary-button col-span-2 min-h-11 rounded-xl px-7 text-sm font-extrabold disabled:opacity-40 sm:col-span-1">Submit group ({selectedIds.length}/4)</button>
|
||||
</div>
|
||||
</ArcadeGameShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionsResults({
|
||||
result,
|
||||
itemById,
|
||||
saveState,
|
||||
onReplay,
|
||||
exitHref,
|
||||
}: {
|
||||
result: ArcadeRoundResult;
|
||||
itemById: Map<string, { id: string; text: string }>;
|
||||
saveState: "idle" | "saving" | "saved" | "error";
|
||||
onReplay: () => void;
|
||||
exitHref: string;
|
||||
}) {
|
||||
const orderedGroups = [...result.groups].sort((a, b) => Number(a.solved) - Number(b.solved));
|
||||
return (
|
||||
<div className="animate-connections-results-in">
|
||||
<section className="connections-results-hero rounded-3xl p-6 text-white sm:p-8">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.18em] text-white/55">Round complete</p>
|
||||
<h2 className="editorial-title mt-1 text-4xl text-white">{result.outcome === "WON" ? "Board cleared" : "Groups revealed"}</h2>
|
||||
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">{[["Score", `${result.score}/${result.maxScore}`], ["Accuracy", `${Math.round(result.accuracy * 100)}%`], ["Mistakes", result.mistakes], ["Time", `${result.durationSeconds}s`]].map(([label, value]) => <div key={label} className="rounded-2xl bg-white/8 p-3"><p className="text-xs font-bold uppercase text-white/45">{label}</p><p className="mt-1 text-xl font-extrabold">{value}</p></div>)}</div>
|
||||
<p className="mt-4 text-xs text-white/55">{saveState === "saving" ? "Saving attempt…" : saveState === "error" ? "Attempt could not be saved." : "Attempt saved."}</p>
|
||||
</section>
|
||||
<div className="mt-5 space-y-3">{orderedGroups.map((group, index) => <article key={group.groupId} className={`connections-review-card connections-group-${index % 4} rounded-2xl p-4`}><p className="text-xs font-extrabold uppercase tracking-wide opacity-60">{group.solved ? "Solved" : "Revealed"}</p><h3 className="mt-1 text-lg font-extrabold">{group.category}</h3><p className="mt-1 text-sm font-semibold opacity-75">{group.items.join(" · ")}</p><p className="mt-3 text-sm leading-6 opacity-80">{group.explanation}</p></article>)}</div>
|
||||
{result.incorrectSelections.length > 0 && <section className="mt-5 rounded-2xl border border-border-light bg-bg-surface p-4"><h3 className="font-extrabold text-text-heading">Incorrect selections</h3><ul className="mt-2 space-y-1 text-sm text-text-secondary">{result.incorrectSelections.map((selection, index) => <li key={`${selection.join("-")}-${index}`}>{selection.map((id) => itemById.get(id)?.text ?? id).join(" · ")}</li>)}</ul></section>}
|
||||
<div className="mt-6 grid grid-cols-2 gap-3"><button onClick={onReplay} className="min-h-12 rounded-xl bg-primary px-5 text-sm font-extrabold text-white">Play again</button><a href={exitHref} className="flex min-h-12 items-center justify-center rounded-xl border border-border bg-bg-surface px-5 text-sm font-extrabold text-text-heading">Back to packs</a></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompletionTransition({ result }: { result: ArcadeRoundResult }) {
|
||||
const won = result.outcome === "WON";
|
||||
return (
|
||||
<div className={`connections-completion ${won ? "is-win" : "is-loss"}`}>
|
||||
{won && <ConfettiBurst />}
|
||||
<div className="connections-completion-mark" aria-hidden>{won ? "✓" : "!"}</div>
|
||||
<p>{won ? "Perfect connection" : "Round complete"}</p>
|
||||
<h2>{won ? "Board cleared!" : "Groups revealed"}</h2>
|
||||
<span>{won ? `${result.score} points` : "Let’s review the board"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfettiBurst() {
|
||||
const colors = ["#ffd166", "#ef476f", "#06d6a0", "#4cc9f0", "#a78bfa"];
|
||||
return <div className="connections-confetti" aria-hidden>{Array.from({ length: 52 }, (_, index) => {
|
||||
const style = {
|
||||
"--confetti-x": `${(index * 37) % 100}%`,
|
||||
"--confetti-delay": `${(index % 13) * 0.035}s`,
|
||||
"--confetti-spin": `${180 + (index % 7) * 80}deg`,
|
||||
"--confetti-color": colors[index % colors.length],
|
||||
} as CSSProperties;
|
||||
return <i key={index} style={style} />;
|
||||
})}</div>;
|
||||
}
|
||||
123
src/components/arcade/ConnectionsHub.tsx
Normal file
123
src/components/arcade/ConnectionsHub.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArcadeImportModal } from "@/components/arcade/ArcadeImportModal";
|
||||
import type { ArcadeAttemptSummary, ArcadePackSummary } from "@/types/arcade";
|
||||
|
||||
export function ConnectionsHub({
|
||||
classId,
|
||||
classSlug,
|
||||
initialPacks,
|
||||
}: {
|
||||
classId: string;
|
||||
classSlug: string;
|
||||
initialPacks: ArcadePackSummary[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [selectedId, setSelectedId] = useState(initialPacks[0]?.id ?? "");
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [allowedMistakes, setAllowedMistakes] = useState(initialPacks[0]?.defaultAllowedMistakes ?? 4);
|
||||
const [oneAwayFeedback, setOneAwayFeedback] = useState(true);
|
||||
const [attempts, setAttempts] = useState<ArcadeAttemptSummary[]>([]);
|
||||
const [attemptsLoading, setAttemptsLoading] = useState(initialPacks.length > 0);
|
||||
const effectiveSelectedId = initialPacks.some((pack) => pack.id === selectedId)
|
||||
? selectedId
|
||||
: initialPacks[0]?.id ?? "";
|
||||
const selected = useMemo(() => initialPacks.find((pack) => pack.id === effectiveSelectedId) ?? null, [effectiveSelectedId, initialPacks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
fetch(`/api/arcade/packs/${selected.id}/attempts`)
|
||||
.then((response) => response.ok ? response.json() : [])
|
||||
.then(setAttempts)
|
||||
.finally(() => setAttemptsLoading(false));
|
||||
}, [selected]);
|
||||
|
||||
function selectPack(pack: ArcadePackSummary) {
|
||||
setSelectedId(pack.id);
|
||||
setAllowedMistakes(pack.defaultAllowedMistakes);
|
||||
setAttempts([]);
|
||||
setAttemptsLoading(true);
|
||||
}
|
||||
|
||||
async function renamePack(pack: ArcadePackSummary) {
|
||||
const name = window.prompt("Rename Connections pack", pack.name)?.trim();
|
||||
if (!name || name === pack.name) return;
|
||||
await fetch(`/api/arcade/packs/${pack.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function deletePack(pack: ArcadePackSummary) {
|
||||
if (!window.confirm(`Delete “${pack.name}” and its attempt history?`)) return;
|
||||
await fetch(`/api/arcade/packs/${pack.id}`, { method: "DELETE" });
|
||||
if (selectedId === pack.id) setSelectedId("");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const playHref = selected
|
||||
? `/${classSlug}/arcade/connections/${selected.id}/play?mistakes=${allowedMistakes}&oneAway=${oneAwayFeedback ? "1" : "0"}`
|
||||
: "#";
|
||||
|
||||
return (
|
||||
<div className="connections-world connections-hub p-1 pb-20 sm:p-3">
|
||||
<div className="connections-hub-hero mb-7 flex flex-col gap-4 rounded-3xl p-5 sm:flex-row sm:items-end sm:justify-between sm:p-7">
|
||||
<div>
|
||||
<Link href={`/${classSlug}/arcade`} className="mb-3 inline-flex min-h-10 items-center text-sm font-bold text-text-muted hover:text-primary">← Back to Arcade</Link>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.18em] text-primary">The pattern room</p>
|
||||
<h2 className="editorial-title mt-1 text-4xl text-text-heading">Connections</h2>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-text-secondary">Choose a pack, tune the mistake limit, and find the four hidden groups.</p>
|
||||
</div>
|
||||
<button onClick={() => setShowImport(true)} className="connections-primary-button min-h-12 rounded-xl px-5 text-sm font-bold">Import pack</button>
|
||||
</div>
|
||||
|
||||
{initialPacks.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-border bg-bg-surface px-6 py-16 text-center">
|
||||
<div className="mx-auto mb-4 grid w-fit grid-cols-2 gap-1.5" aria-hidden>{["bg-amber-400", "bg-emerald-500", "bg-sky-500", "bg-violet-500"].map((color) => <span key={color} className={`h-8 w-8 rounded-lg ${color}`} />)}</div>
|
||||
<h3 className="editorial-title text-2xl text-text-heading">Import your first board</h3>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-text-secondary">Connections uses purpose-built packs with four groups of four terms.</p>
|
||||
<button onClick={() => setShowImport(true)} className="mt-6 min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white">Import Connections JSON</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.15fr)_minmax(20rem,.85fr)]">
|
||||
<section>
|
||||
<h3 className="mb-3 text-sm font-extrabold uppercase tracking-[0.14em] text-text-muted">Study packs</h3>
|
||||
<div className="space-y-3">
|
||||
{initialPacks.map((pack) => {
|
||||
const active = pack.id === effectiveSelectedId;
|
||||
return (
|
||||
<article key={pack.id} className={`connections-pack-card rounded-2xl border p-4 transition-all ${active ? "is-active" : ""}`}>
|
||||
<button onClick={() => selectPack(pack)} className="w-full text-left">
|
||||
<div className="flex items-start justify-between gap-3"><div><h4 className="text-lg font-extrabold text-text-heading">{pack.name}</h4>{pack.description && <p className="mt-1 text-sm leading-5 text-text-secondary">{pack.description}</p>}</div><span className={`mt-1 h-4 w-4 shrink-0 rounded-full border-4 ${active ? "border-primary bg-white" : "border-border bg-bg-surface"}`} aria-hidden /></div>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs font-bold text-text-muted"><span className="rounded-full bg-bg-surface-alt px-2.5 py-1">16 tiles</span><span className="rounded-full bg-bg-surface-alt px-2.5 py-1">Best {pack.bestScore ?? "—"}/400</span><span className="rounded-full bg-bg-surface-alt px-2.5 py-1">Latest {pack.latestAttempt?.score ?? "—"}</span></div>
|
||||
</button>
|
||||
<div className="mt-3 flex justify-end gap-2 border-t border-border-light pt-3"><button onClick={() => renamePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-bg-surface-alt hover:text-text-heading">Rename</button><button onClick={() => deletePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-error-bg hover:text-error">Delete</button></div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="connections-setup h-fit rounded-3xl border border-border-light bg-bg-surface p-5 lg:sticky lg:top-6">
|
||||
<h3 className="editorial-title text-2xl text-text-heading">Round setup</h3>
|
||||
{selected ? <>
|
||||
<p className="mt-1 text-sm font-bold text-primary">{selected.name}</p>
|
||||
<label className="mt-5 block text-sm font-bold text-text-heading">Allowed mistakes <span className="text-primary">{allowedMistakes}</span></label>
|
||||
<input type="range" min={1} max={8} value={allowedMistakes} onChange={(event) => setAllowedMistakes(Number(event.target.value))} className="mt-2 w-full accent-primary" />
|
||||
<label className="mt-5 flex items-start gap-3 rounded-xl bg-bg-surface-alt p-3 text-sm text-text-secondary"><input type="checkbox" checked={oneAwayFeedback} onChange={(event) => setOneAwayFeedback(event.target.checked)} className="mt-1" /><span><strong className="block text-text-heading">One-away feedback</strong>Tell me when three selected tiles belong together.</span></label>
|
||||
<div className="mt-5 rounded-xl border border-border-light p-3 text-sm text-text-secondary"><div className="flex justify-between"><span>Board</span><strong className="text-text-heading">4 × 4</strong></div><div className="mt-2 flex justify-between"><span>Possible score</span><strong className="text-text-heading">400</strong></div></div>
|
||||
<Link href={playHref} className="connections-primary-button mt-5 flex min-h-12 items-center justify-center rounded-xl px-5 text-sm font-extrabold">Enter the board</Link>
|
||||
<div className="mt-7 border-t border-border-light pt-5"><h4 className="text-sm font-extrabold uppercase tracking-wide text-text-muted">Recent attempts</h4>{attemptsLoading ? <p className="mt-3 text-sm text-text-muted">Loading history…</p> : attempts.length === 0 ? <p className="mt-3 text-sm text-text-muted">No attempts yet.</p> : <div className="mt-3 space-y-2">{attempts.slice(0, 5).map((attempt) => <div key={attempt.id} className="flex items-center justify-between rounded-xl bg-bg-surface-alt px-3 py-2 text-sm"><span className="font-bold text-text-heading">{attempt.score}/400</span><span className="text-text-muted">{Math.round(attempt.accuracy * 100)}% · {attempt.durationSeconds}s</span></div>)}</div>}</div>
|
||||
</> : <p className="mt-3 text-sm text-text-muted">Select a pack to configure the round.</p>}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
{showImport && <ArcadeImportModal classId={classId} gameType="connections" onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); router.refresh(); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
269
src/components/arcade/CrosswordGame.tsx
Normal file
269
src/components/arcade/CrosswordGame.tsx
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { ArcadeGameShell } from "@/components/arcade/ArcadeGameShell";
|
||||
import type {
|
||||
CrosswordAction,
|
||||
CrosswordLayout,
|
||||
CrosswordPlacedEntry,
|
||||
CrosswordRoundResult,
|
||||
CrosswordSessionSettings,
|
||||
NormalizedCrosswordPack,
|
||||
} from "@/types/arcade";
|
||||
|
||||
function entryValue(entry: CrosswordPlacedEntry, answers: Record<string, string>) {
|
||||
return entry.cellKeys.map((cellKey) => answers[cellKey] ?? "").join("");
|
||||
}
|
||||
|
||||
export function CrosswordGame({ seed, packId, packName, classSlug, layout, settings }: {
|
||||
seed: string;
|
||||
packId: string;
|
||||
packName: string;
|
||||
classSlug: string;
|
||||
pack: NormalizedCrosswordPack;
|
||||
layout: CrosswordLayout;
|
||||
settings: CrosswordSessionSettings;
|
||||
}) {
|
||||
const orderedEntries = useMemo(() => [...layout.entries].sort((a, b) => a.number - b.number || a.direction.localeCompare(b.direction)), [layout.entries]);
|
||||
const entryById = useMemo(() => new Map(orderedEntries.map((entry) => [entry.id, entry])), [orderedEntries]);
|
||||
const cellByKey = useMemo(() => new Map(layout.cells.map((cell) => [cell.key, cell])), [layout.cells]);
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
const [activeEntryId, setActiveEntryId] = useState(orderedEntries[0]?.id ?? "");
|
||||
const [activeCellKey, setActiveCellKey] = useState(orderedEntries[0]?.cellKeys[0] ?? "");
|
||||
const [clueTab, setClueTab] = useState<"across" | "down">(orderedEntries[0]?.direction ?? "across");
|
||||
const [actions, setActions] = useState<CrosswordAction[]>([]);
|
||||
const [checkedWrong, setCheckedWrong] = useState<Set<string>>(new Set());
|
||||
const [revealedCells, setRevealedCells] = useState<Set<string>>(new Set());
|
||||
const [alternateClues, setAlternateClues] = useState<Set<string>>(new Set());
|
||||
const [feedback, setFeedback] = useState(`${layout.entries.length} of ${layout.targetCount} target words placed.`);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
const [result, setResult] = useState<CrosswordRoundResult | null>(null);
|
||||
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
|
||||
const startedAt = useRef(0);
|
||||
const mobileInput = useRef<HTMLInputElement>(null);
|
||||
const activeEntry = entryById.get(activeEntryId) ?? orderedEntries[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (startedAt.current === 0) startedAt.current = Date.now();
|
||||
if (result) return;
|
||||
const timer = window.setInterval(() => setElapsedSeconds(Math.floor((Date.now() - startedAt.current) / 1000)), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [result]);
|
||||
|
||||
function selectEntry(entry: CrosswordPlacedEntry, cellKey = entry.cellKeys[0]) {
|
||||
setActiveEntryId(entry.id);
|
||||
setActiveCellKey(cellKey);
|
||||
setClueTab(entry.direction);
|
||||
}
|
||||
|
||||
function selectCell(cellKey: string) {
|
||||
const cell = cellByKey.get(cellKey);
|
||||
if (!cell) return;
|
||||
let nextId = cell.entryIds[0];
|
||||
if (cell.entryIds.includes(activeEntryId) && cell.entryIds.length > 1 && activeCellKey === cellKey) {
|
||||
nextId = cell.entryIds.find((id) => id !== activeEntryId) ?? nextId;
|
||||
} else if (!cell.entryIds.includes(activeEntryId)) {
|
||||
nextId = cell.entryIds.find((id) => entryById.get(id)?.direction === clueTab) ?? nextId;
|
||||
} else {
|
||||
nextId = activeEntryId;
|
||||
}
|
||||
const entry = entryById.get(nextId);
|
||||
if (entry) selectEntry(entry, cellKey);
|
||||
mobileInput.current?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function moveWithinEntry(offset: number) {
|
||||
if (!activeEntry) return;
|
||||
const index = Math.max(0, activeEntry.cellKeys.indexOf(activeCellKey));
|
||||
setActiveCellKey(activeEntry.cellKeys[Math.max(0, Math.min(activeEntry.cellKeys.length - 1, index + offset))]);
|
||||
}
|
||||
|
||||
function writeLetter(letter: string) {
|
||||
if (!activeEntry || !activeCellKey || result || revealedCells.has(activeCellKey)) return;
|
||||
const upper = letter.replace(/[^A-Za-z]/g, "").slice(-1).toUpperCase();
|
||||
if (!upper) return;
|
||||
const nextAnswers = { ...answers, [activeCellKey]: upper };
|
||||
setAnswers(nextAnswers);
|
||||
if (settings.instantCheck) {
|
||||
const value = activeEntry.cellKeys.map((cellKey) => nextAnswers[cellKey] ?? "").join("");
|
||||
if (value.length === activeEntry.answer.length) {
|
||||
setCheckedWrong((checked) => {
|
||||
const updated = new Set(checked);
|
||||
activeEntry.cellKeys.forEach((cellKey, index) => {
|
||||
if ((nextAnswers[cellKey] ?? "") === activeEntry.answer[index]) updated.delete(cellKey);
|
||||
else updated.add(cellKey);
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}
|
||||
setCheckedWrong((current) => { const next = new Set(current); next.delete(activeCellKey); return next; });
|
||||
moveWithinEntry(1);
|
||||
}
|
||||
|
||||
function clearLetter() {
|
||||
if (!activeCellKey || revealedCells.has(activeCellKey)) return;
|
||||
if (answers[activeCellKey]) {
|
||||
setAnswers((current) => { const next = { ...current }; delete next[activeCellKey]; return next; });
|
||||
setCheckedWrong((current) => { const next = new Set(current); next.delete(activeCellKey); return next; });
|
||||
} else {
|
||||
moveWithinEntry(-1);
|
||||
}
|
||||
}
|
||||
|
||||
function cycleEntry(offset: number) {
|
||||
const index = Math.max(0, orderedEntries.findIndex((entry) => entry.id === activeEntryId));
|
||||
const entry = orderedEntries[(index + offset + orderedEntries.length) % orderedEntries.length];
|
||||
selectEntry(entry);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent) {
|
||||
if (/^[a-zA-Z]$/.test(event.key)) { event.preventDefault(); writeLetter(event.key); return; }
|
||||
if (event.key === "Backspace" || event.key === "Delete") { event.preventDefault(); clearLetter(); return; }
|
||||
if (event.key === "Tab") { event.preventDefault(); cycleEntry(event.shiftKey ? -1 : 1); return; }
|
||||
if (event.key === "Enter") { event.preventDefault(); checkWord(); return; }
|
||||
const cell = cellByKey.get(activeCellKey);
|
||||
if (!cell || !event.key.startsWith("Arrow")) return;
|
||||
event.preventDefault();
|
||||
const delta = event.key === "ArrowLeft" ? [0, -1] : event.key === "ArrowRight" ? [0, 1] : event.key === "ArrowUp" ? [-1, 0] : [1, 0];
|
||||
const next = cellByKey.get(`${cell.row + delta[0]}:${cell.column + delta[1]}`);
|
||||
if (next) selectCell(next.key);
|
||||
}
|
||||
|
||||
function incorrectCells(entry: CrosswordPlacedEntry) {
|
||||
return entry.cellKeys.filter((cellKey, index) => (answers[cellKey] ?? "") !== entry.answer[index]);
|
||||
}
|
||||
|
||||
function checkWord() {
|
||||
if (!activeEntry) return;
|
||||
const wrong = incorrectCells(activeEntry);
|
||||
setCheckedWrong((current) => new Set([...current, ...wrong]));
|
||||
setActions((current) => [...current, { type: "CHECK_WORD", entryId: activeEntry.id, value: entryValue(activeEntry, answers), elapsedMs: Date.now() - startedAt.current }]);
|
||||
setFeedback(wrong.length === 0 ? `${activeEntry.number} ${activeEntry.direction} is correct.` : `${wrong.length} cell${wrong.length === 1 ? " is" : "s are"} incorrect in this word.`);
|
||||
}
|
||||
|
||||
function checkPuzzle() {
|
||||
const wrong = orderedEntries.flatMap(incorrectCells);
|
||||
setCheckedWrong(new Set(wrong));
|
||||
setActions((current) => [...current, { type: "CHECK_PUZZLE", answers: Object.fromEntries(orderedEntries.map((entry) => [entry.id, entryValue(entry, answers)])), elapsedMs: Date.now() - startedAt.current }]);
|
||||
setFeedback(wrong.length === 0 ? "Every filled answer is correct." : `${wrong.length} cells need another look.`);
|
||||
}
|
||||
|
||||
function showAlternateClue() {
|
||||
if (!activeEntry || !settings.allowHints) return;
|
||||
setAlternateClues((current) => new Set(current).add(activeEntry.id));
|
||||
setActions((current) => [...current, { type: "ALTERNATE_CLUE", entryId: activeEntry.id, elapsedMs: Date.now() - startedAt.current }]);
|
||||
}
|
||||
|
||||
function revealLetter() {
|
||||
if (!activeEntry || !activeCellKey || !settings.allowHints) return;
|
||||
const index = activeEntry.cellKeys.indexOf(activeCellKey);
|
||||
setAnswers((current) => ({ ...current, [activeCellKey]: activeEntry.answer[index] }));
|
||||
setRevealedCells((current) => new Set(current).add(activeCellKey));
|
||||
setCheckedWrong((current) => { const next = new Set(current); next.delete(activeCellKey); return next; });
|
||||
setActions((current) => [...current, { type: "REVEAL_LETTER", cellKey: activeCellKey, elapsedMs: Date.now() - startedAt.current }]);
|
||||
moveWithinEntry(1);
|
||||
}
|
||||
|
||||
function revealWord() {
|
||||
if (!activeEntry || !settings.allowHints) return;
|
||||
setAnswers((current) => ({ ...current, ...Object.fromEntries(activeEntry.cellKeys.map((cellKey, index) => [cellKey, activeEntry.answer[index]])) }));
|
||||
setRevealedCells((current) => new Set([...current, ...activeEntry.cellKeys]));
|
||||
setCheckedWrong((current) => { const next = new Set(current); activeEntry.cellKeys.forEach((cellKey) => next.delete(cellKey)); return next; });
|
||||
setActions((current) => [...current, { type: "REVEAL_WORD", entryId: activeEntry.id, elapsedMs: Date.now() - startedAt.current }]);
|
||||
setFeedback(`${activeEntry.number} ${activeEntry.direction} was revealed.`);
|
||||
}
|
||||
|
||||
async function finish(gaveUp: boolean) {
|
||||
if (saveState === "saving") return;
|
||||
if (!gaveUp && !window.confirm("Submit this puzzle and reveal the results?")) return;
|
||||
if (gaveUp && !window.confirm("Give up and reveal every remaining answer?")) return;
|
||||
setSaveState("saving");
|
||||
const durationSeconds = Math.floor((Date.now() - startedAt.current) / 1000);
|
||||
const response = await fetch(`/api/arcade/packs/${packId}/attempts`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mode: "CROSSWORD",
|
||||
durationSeconds,
|
||||
seed,
|
||||
settings,
|
||||
finalAnswers: Object.fromEntries(orderedEntries.map((entry) => [entry.id, entryValue(entry, answers)])),
|
||||
actions,
|
||||
gaveUp,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.results) { setSaveState("error"); setFeedback(data.error ?? "Attempt could not be saved."); return; }
|
||||
setElapsedSeconds(durationSeconds);
|
||||
setResult(data.results);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
return <ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/crossword`} elapsedSeconds={elapsedSeconds} complete worldClassName="crossword-world crossword-stage"><CrosswordResults result={result} layout={layout} answers={answers} revealedCells={revealedCells} exitHref={`/${classSlug}/arcade/crossword`} /></ArcadeGameShell>;
|
||||
}
|
||||
|
||||
const activeKeys = new Set(activeEntry?.cellKeys ?? []);
|
||||
const visibleClues = orderedEntries.filter((entry) => entry.direction === clueTab);
|
||||
const cellSize = Math.round(34 * zoom);
|
||||
return (
|
||||
<ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/crossword`} elapsedSeconds={elapsedSeconds} complete={false} worldClassName="crossword-world crossword-stage">
|
||||
<input ref={mobileInput} value="" onKeyDown={handleKeyDown} onChange={(event) => writeLetter(event.target.value)} className="crossword-mobile-input" aria-label="Type crossword letter" autoCapitalize="characters" inputMode="text" />
|
||||
<section className="crossword-active-clue mb-4 rounded-2xl p-4" aria-live="polite"><span>{activeEntry?.number} {activeEntry?.direction}</span><strong>{alternateClues.has(activeEntry?.id ?? "") ? activeEntry?.alternateClue : activeEntry?.clue}</strong><small>{feedback}</small></section>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_22rem]">
|
||||
<div>
|
||||
<div className="crossword-toolbar mb-3 flex flex-wrap items-center justify-between gap-2 rounded-xl p-2"><div className="flex gap-1"><button onClick={() => setZoom((value) => Math.max(.7, value - .1))} aria-label="Zoom out">−</button><span>{Math.round(zoom * 100)}%</span><button onClick={() => setZoom((value) => Math.min(1.5, value + .1))} aria-label="Zoom in">+</button></div><span>{layout.entries.length} placed · {layout.omittedEntries.length} omitted</span></div>
|
||||
<div className="crossword-board-viewport rounded-2xl" onKeyDown={handleKeyDown} tabIndex={0} aria-label="Crossword grid">
|
||||
<div className="crossword-board" style={{ "--crossword-cell": `${cellSize}px`, gridTemplateColumns: `repeat(${layout.columns}, var(--crossword-cell))`, gridTemplateRows: `repeat(${layout.rows}, var(--crossword-cell))` } as CSSProperties}>
|
||||
{layout.cells.map((cell) => <button key={cell.key} onClick={() => selectCell(cell.key)} aria-label={`Row ${cell.row + 1}, column ${cell.column + 1}${answers[cell.key] ? `, ${answers[cell.key]}` : ""}`} className={`crossword-cell ${activeKeys.has(cell.key) ? "is-word" : ""} ${activeCellKey === cell.key ? "is-active" : ""} ${checkedWrong.has(cell.key) ? "is-wrong" : ""} ${revealedCells.has(cell.key) ? "is-revealed" : ""}`} style={{ gridRow: cell.row + 1, gridColumn: cell.column + 1 }}><small>{cell.number}</small><strong>{answers[cell.key] ?? ""}</strong></button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
|
||||
<button onClick={checkWord} className="crossword-secondary">Check word</button><button onClick={checkPuzzle} className="crossword-secondary">Check puzzle</button>
|
||||
{settings.allowHints && <><button onClick={showAlternateClue} className="crossword-secondary">Alternate clue</button><button onClick={revealLetter} className="crossword-secondary">Reveal letter</button><button onClick={revealWord} className="crossword-secondary">Reveal word</button></>}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3"><button onClick={() => void finish(true)} className="crossword-secondary min-h-12">Give up</button><button onClick={() => void finish(false)} className="crossword-primary min-h-12 rounded-xl font-extrabold">Submit puzzle</button></div>
|
||||
{saveState === "error" && <p className="mt-2 text-sm text-error" role="alert">The attempt could not be saved. Your puzzle is still open.</p>}
|
||||
</div>
|
||||
<aside className="crossword-clues rounded-2xl p-4">
|
||||
<div className="grid grid-cols-2 gap-2">{(["across", "down"] as const).map((tab) => <button key={tab} aria-pressed={clueTab === tab} onClick={() => setClueTab(tab)} className={clueTab === tab ? "is-active" : ""}>{tab}</button>)}</div>
|
||||
<ol className="mt-4 space-y-2">{visibleClues.map((entry) => <li key={entry.id}><button onClick={() => selectEntry(entry)} className={entry.id === activeEntryId ? "is-active" : ""}><b>{entry.number}</b><span>{alternateClues.has(entry.id) ? entry.alternateClue : entry.clue}</span></button></li>)}</ol>
|
||||
</aside>
|
||||
</div>
|
||||
</ArcadeGameShell>
|
||||
);
|
||||
}
|
||||
|
||||
function CrosswordResults({ result, layout, answers, revealedCells, exitHref }: { result: CrosswordRoundResult; layout: CrosswordLayout; answers: Record<string, string>; revealedCells: Set<string>; exitHref: string }) {
|
||||
const ordered = result.entries.filter((entry) => !entry.omitted).sort((left, right) => {
|
||||
const rank = (entry: typeof left) => !entry.correct ? 0 : entry.revealedWord || entry.revealedLetters > 0 || entry.alternateClueUsed ? 1 : 2;
|
||||
return rank(left) - rank(right);
|
||||
});
|
||||
return <div className="crossword-results">
|
||||
<section className="crossword-results-hero rounded-3xl p-6 sm:p-8"><p className="crossword-kicker">Final edition</p><h2 className="editorial-title mt-1 text-4xl">{result.outcome === "GAVE_UP" ? "Answers revealed" : "Puzzle submitted"}</h2><div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">{[["Score", `${result.score}/${result.maxScore}`], ["Accuracy", `${Math.round(result.accuracy * 100)}%`], ["Hints", result.hintsUsed], ["Words", result.placedCount]].map(([label, value]) => <div key={label} className="rounded-xl p-3"><small>{label}</small><strong>{value}</strong></div>)}</div></section>
|
||||
<FinalCrosswordBoard layout={layout} answers={answers} revealedCells={revealedCells} />
|
||||
<div className="mt-5 space-y-3">{ordered.map((entry) => {
|
||||
const assisted = entry.revealedWord || entry.revealedLetters > 0;
|
||||
return <article key={entry.entryId} className={`crossword-review rounded-2xl p-4 ${assisted ? "is-assisted" : entry.correct ? "is-correct" : "is-incorrect"}`}><p className="text-xs font-extrabold uppercase tracking-wide">{assisted ? "Assisted" : entry.correct ? "Correct" : "Incorrect"}</p><h3 className="mt-1 text-lg font-extrabold">{entry.answer}</h3><p className="mt-1 text-sm"><strong>Clue:</strong> {entry.clue}</p><p className="mt-1 text-sm"><strong>Your answer:</strong> {entry.playerAnswer || "No answer"}</p><p className="mt-3 text-sm leading-6">{entry.explanation}</p></article>;
|
||||
})}</div>
|
||||
<div className="mt-6 grid grid-cols-2 gap-3"><button onClick={() => window.location.reload()} className="crossword-primary min-h-12 rounded-xl font-extrabold">New layout</button><a href={exitHref} className="crossword-secondary flex min-h-12 items-center justify-center">Back to banks</a></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function FinalCrosswordBoard({ layout, answers, revealedCells }: { layout: CrosswordLayout; answers: Record<string, string>; revealedCells: Set<string> }) {
|
||||
const cellSize = Math.max(18, Math.min(30, Math.floor(640 / Math.max(layout.rows, layout.columns))));
|
||||
return <section className="crossword-final-board mt-5 rounded-3xl p-4 sm:p-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3"><div><p className="crossword-kicker">Completed grid</p><h3 className="editorial-title mt-1 text-2xl">Your final board</h3></div><div className="crossword-final-legend"><span><i className="is-correct" />Correct</span><span><i className="is-assisted" />Revealed or assisted</span><span><i className="is-incorrect" />Incorrect or blank</span></div></div>
|
||||
<div className="crossword-final-viewport mt-4">
|
||||
<div className="crossword-final-grid" style={{ gridTemplateColumns: `repeat(${layout.columns}, ${cellSize}px)`, gridTemplateRows: `repeat(${layout.rows}, ${cellSize}px)` }}>
|
||||
{layout.cells.map((cell) => {
|
||||
const assisted = revealedCells.has(cell.key);
|
||||
const correct = answers[cell.key] === cell.answer;
|
||||
const state = assisted ? "assisted" : correct ? "correct" : "incorrect";
|
||||
return <div key={cell.key} className={`crossword-final-cell is-${state}`} style={{ gridColumn: cell.column + 1, gridRow: cell.row + 1 }} aria-label={`${cell.answer}, ${state === "incorrect" ? "incorrect or blank" : state}`}><small>{cell.number}</small><strong>{cell.answer}</strong></div>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
152
src/components/arcade/CrosswordHub.tsx
Normal file
152
src/components/arcade/CrosswordHub.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArcadeImportModal } from "@/components/arcade/ArcadeImportModal";
|
||||
import { CROSSWORD_SIZE_TARGETS } from "@/lib/arcade/crosswordEngine";
|
||||
import type { ArcadeAttemptSummary, ArcadePackSummary, CrosswordLayout, CrosswordSize } from "@/types/arcade";
|
||||
|
||||
const SIZES: { value: CrosswordSize; label: string; note: string }[] = [
|
||||
{ value: "mini", label: "Mini", note: "Up to 15 words" },
|
||||
{ value: "standard", label: "Standard", note: "Up to 30 words" },
|
||||
{ value: "large", label: "Large", note: "Up to 50 words" },
|
||||
{ value: "extra-large", label: "Extra Large", note: "Up to 80 words" },
|
||||
];
|
||||
|
||||
export function CrosswordHub({ classId, classSlug, initialPacks }: { classId: string; classSlug: string; initialPacks: ArcadePackSummary[] }) {
|
||||
const router = useRouter();
|
||||
const [selectedId, setSelectedId] = useState(initialPacks[0]?.id ?? "");
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [size, setSize] = useState<CrosswordSize>("standard");
|
||||
const [instantCheck, setInstantCheck] = useState(initialPacks[0]?.defaultInstantCheck ?? false);
|
||||
const [allowHints, setAllowHints] = useState(initialPacks[0]?.defaultAllowHints ?? true);
|
||||
const [attempts, setAttempts] = useState<ArcadeAttemptSummary[]>([]);
|
||||
const [attemptsLoading, setAttemptsLoading] = useState(initialPacks.length > 0);
|
||||
const [previewLayout, setPreviewLayout] = useState<CrosswordLayout | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(initialPacks.length > 0);
|
||||
const effectiveSelectedId = initialPacks.some((pack) => pack.id === selectedId) ? selectedId : initialPacks[0]?.id ?? "";
|
||||
const selected = useMemo(() => initialPacks.find((pack) => pack.id === effectiveSelectedId) ?? null, [effectiveSelectedId, initialPacks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
fetch(`/api/arcade/packs/${selected.id}/attempts`)
|
||||
.then((response) => response.ok ? response.json() : [])
|
||||
.then(setAttempts)
|
||||
.finally(() => setAttemptsLoading(false));
|
||||
}, [selected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
let current = true;
|
||||
fetch(`/api/arcade/packs/${selected.id}/layout?size=${size}`)
|
||||
.then((response) => response.ok ? response.json() : null)
|
||||
.then((layout) => { if (current) setPreviewLayout(layout); })
|
||||
.finally(() => { if (current) setPreviewLoading(false); });
|
||||
return () => { current = false; };
|
||||
}, [selected, size]);
|
||||
|
||||
function selectPack(pack: ArcadePackSummary) {
|
||||
if (pack.id === effectiveSelectedId) return;
|
||||
setSelectedId(pack.id);
|
||||
setInstantCheck(pack.defaultInstantCheck);
|
||||
setAllowHints(pack.defaultAllowHints);
|
||||
setAttempts([]);
|
||||
setAttemptsLoading(true);
|
||||
setPreviewLayout(null);
|
||||
setPreviewLoading(true);
|
||||
}
|
||||
|
||||
function selectSize(nextSize: CrosswordSize) {
|
||||
if (nextSize === size) return;
|
||||
setSize(nextSize);
|
||||
setPreviewLayout(null);
|
||||
setPreviewLoading(true);
|
||||
}
|
||||
|
||||
async function renamePack(pack: ArcadePackSummary) {
|
||||
const name = window.prompt("Rename Crossword pack", pack.name)?.trim();
|
||||
if (!name || name === pack.name) return;
|
||||
await fetch(`/api/arcade/packs/${pack.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) });
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function deletePack(pack: ArcadePackSummary) {
|
||||
if (!window.confirm(`Delete “${pack.name}” and its attempt history?`)) return;
|
||||
await fetch(`/api/arcade/packs/${pack.id}`, { method: "DELETE" });
|
||||
if (selectedId === pack.id) setSelectedId("");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const playHref = selected
|
||||
? `/${classSlug}/arcade/crossword/${selected.id}/play?size=${size}&instant=${instantCheck ? "1" : "0"}&hints=${allowHints ? "1" : "0"}`
|
||||
: "#";
|
||||
|
||||
return (
|
||||
<div className="crossword-world crossword-hub p-1 pb-20 sm:p-3">
|
||||
<section className="crossword-hero mb-7 rounded-3xl p-5 sm:flex sm:items-end sm:justify-between sm:p-7">
|
||||
<div>
|
||||
<Link href={`/${classSlug}/arcade`} className="mb-3 inline-flex min-h-10 items-center text-sm font-bold text-text-muted hover:text-primary">← Back to Arcade</Link>
|
||||
<p className="crossword-kicker">The evening edition</p>
|
||||
<h2 className="editorial-title mt-1 text-4xl text-text-heading">Crossword</h2>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-text-secondary">Choose a terminology bank, set the edition size, and work the clues at your own pace.</p>
|
||||
</div>
|
||||
<button onClick={() => setShowImport(true)} className="crossword-primary mt-5 min-h-12 rounded-xl px-5 text-sm font-bold sm:mt-0">Import puzzle bank</button>
|
||||
</section>
|
||||
|
||||
{initialPacks.length === 0 ? (
|
||||
<section className="crossword-paper rounded-3xl px-6 py-16 text-center">
|
||||
<div className="mx-auto mb-5 grid w-24 grid-cols-5 gap-1" aria-hidden>{Array.from({ length: 25 }, (_, index) => <i key={index} className={index % 3 === 0 ? "bg-[#2d2117]" : "bg-[#fff8e6]"} />)}</div>
|
||||
<h3 className="editorial-title text-3xl text-text-heading">Print your first edition</h3>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-text-secondary">Import one JSON object containing exactly 80 clue-and-answer entries.</p>
|
||||
<button onClick={() => setShowImport(true)} className="crossword-primary mt-6 min-h-11 rounded-xl px-5 text-sm font-bold">Import Crossword JSON</button>
|
||||
</section>
|
||||
) : (
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(22rem,.9fr)]">
|
||||
<section>
|
||||
<h3 className="crossword-section-label">Puzzle banks</h3>
|
||||
<div className="space-y-3">
|
||||
{initialPacks.map((pack) => {
|
||||
const active = pack.id === effectiveSelectedId;
|
||||
return <article key={pack.id} className={`crossword-pack rounded-2xl border p-4 ${active ? "is-active" : ""}`}>
|
||||
<button onClick={() => selectPack(pack)} className="w-full text-left">
|
||||
<div className="flex items-start justify-between gap-3"><div><h4 className="text-xl font-extrabold text-text-heading">{pack.name}</h4>{pack.description && <p className="mt-1 text-sm leading-5 text-text-secondary">{pack.description}</p>}</div><span className={`mt-1 h-4 w-4 rounded-full border-4 ${active ? "border-primary bg-white" : "border-border bg-bg-surface"}`} aria-hidden /></div>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs font-bold text-text-muted"><span>80 entries</span><span>Best {pack.bestScore ?? "—"}</span><span>Latest {pack.latestAttempt?.score ?? "—"}</span></div>
|
||||
</button>
|
||||
<div className="mt-3 flex justify-end gap-2 border-t border-border-light pt-3"><button onClick={() => renamePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-bg-surface-alt">Rename</button><button onClick={() => deletePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-error-bg hover:text-error">Delete</button></div>
|
||||
</article>;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="crossword-setup h-fit rounded-3xl p-5 lg:sticky lg:top-6">
|
||||
<h3 className="editorial-title text-2xl text-text-heading">Choose an edition</h3>
|
||||
{selected ? <>
|
||||
<p className="mt-1 text-sm font-bold text-primary">{selected.name}</p>
|
||||
<fieldset className="mt-5"><legend className="crossword-section-label">Board size</legend><div className="grid grid-cols-2 gap-2">{SIZES.map((option) => <button type="button" key={option.value} aria-pressed={size === option.value} onClick={() => selectSize(option.value)} className={`crossword-size rounded-xl p-3 text-left ${size === option.value ? "is-active" : ""}`}><strong className="block text-sm">{option.label}</strong><span className="text-xs">{option.note}</span></button>)}</div></fieldset>
|
||||
<CrosswordBoardPreview layout={previewLayout} loading={previewLoading} />
|
||||
<div className="mt-5 space-y-2">
|
||||
<label className="crossword-option"><input type="checkbox" checked={instantCheck} onChange={(event) => setInstantCheck(event.target.checked)} /><span><strong>Instant word checks</strong><small>Check only after a word is filled.</small></span></label>
|
||||
<label className="crossword-option"><input type="checkbox" checked={allowHints} onChange={(event) => setAllowHints(event.target.checked)} /><span><strong>Allow hints</strong><small>Alternate clues and reveals stay available.</small></span></label>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-between border-y border-border-light py-3 text-sm"><span className="text-text-secondary">Target words</span><strong>{CROSSWORD_SIZE_TARGETS[size]}</strong></div>
|
||||
<Link href={playHref} className="crossword-primary mt-5 flex min-h-12 items-center justify-center rounded-xl px-5 text-sm font-extrabold">Open the puzzle</Link>
|
||||
<div className="mt-7 border-t border-border-light pt-5"><h4 className="crossword-section-label">Recent editions</h4>{attemptsLoading ? <p className="mt-3 text-sm text-text-muted">Loading history…</p> : attempts.length === 0 ? <p className="mt-3 text-sm text-text-muted">No attempts yet.</p> : <div className="mt-3 space-y-2">{attempts.slice(0, 5).map((attempt) => <div key={attempt.id} className="flex items-center justify-between rounded-xl bg-bg-surface-alt px-3 py-2 text-sm"><strong>{attempt.score}/{attempt.maxScore}</strong><span className="text-text-muted">{Math.round(attempt.accuracy * 100)}% · {attempt.durationSeconds}s</span></div>)}</div>}</div>
|
||||
</> : <p className="mt-3 text-sm text-text-muted">Select a pack to configure a puzzle.</p>}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
{showImport && <ArcadeImportModal classId={classId} gameType="crossword" onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); router.refresh(); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CrosswordBoardPreview({ layout, loading }: { layout: CrosswordLayout | null; loading: boolean }) {
|
||||
const cellSize = layout ? Math.max(3, Math.min(8, Math.floor(190 / Math.max(layout.rows, layout.columns)))) : 6;
|
||||
return <section className="crossword-board-preview mt-4" aria-label="Selected size board preview" aria-busy={loading}>
|
||||
<div className="crossword-preview-heading"><span>Layout preview</span>{layout && <small>{layout.entries.length} words · {layout.columns} × {layout.rows}</small>}</div>
|
||||
<div className="crossword-preview-canvas">
|
||||
{loading ? <div className="crossword-preview-loading">Composing puzzle…</div> : layout ? <div className="crossword-preview-grid" style={{ gridTemplateColumns: `repeat(${layout.columns}, ${cellSize}px)`, gridTemplateRows: `repeat(${layout.rows}, ${cellSize}px)` }}>{layout.cells.map((cell) => <i key={cell.key} style={{ gridColumn: cell.column + 1, gridRow: cell.row + 1 }} />)}</div> : <p>Preview unavailable</p>}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
7
src/components/arcade/rendererRegistry.ts
Normal file
7
src/components/arcade/rendererRegistry.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { ConnectionsGame } from "@/components/arcade/ConnectionsGame";
|
||||
import { CrosswordGame } from "@/components/arcade/CrosswordGame";
|
||||
|
||||
export const ARCADE_RENDERERS = {
|
||||
connections: ConnectionsGame,
|
||||
crossword: CrosswordGame,
|
||||
} as const;
|
||||
|
|
@ -41,7 +41,6 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
|
|||
body: JSON.stringify({ front: editFront, back: editBack }),
|
||||
});
|
||||
setEditingId(null);
|
||||
window.dispatchEvent(new Event("study-decks-changed"));
|
||||
onCardsChanged();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
|
|
@ -51,7 +50,6 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
|
|||
async function deleteCard(id: string) {
|
||||
if (!confirm("Delete this card?")) return;
|
||||
await fetch(`/api/cards/${id}`, { method: "DELETE" });
|
||||
window.dispatchEvent(new Event("study-decks-changed"));
|
||||
onCardsChanged();
|
||||
}
|
||||
|
||||
|
|
@ -67,7 +65,6 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
|
|||
setNewFront("");
|
||||
setNewBack("");
|
||||
setShowAdd(false);
|
||||
window.dispatchEvent(new Event("study-decks-changed"));
|
||||
onCardsChanged();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
|
|
@ -92,7 +89,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
|
|||
</div>
|
||||
|
||||
{/* Card list */}
|
||||
{cards.map((card) => (
|
||||
{cards.map((card, index) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="overflow-hidden rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)]"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
|||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { recordStudyActivity } from "@/lib/activityClient";
|
||||
import { normalizeProgress } from "@/lib/progressNormalization";
|
||||
import {
|
||||
STREAK_FLAME_THRESHOLD,
|
||||
StreakBurst,
|
||||
|
|
@ -26,78 +25,47 @@ interface ProgressData {
|
|||
currentIndex: number;
|
||||
orderJson: string;
|
||||
cardResultsJson: string | null;
|
||||
sessionId: string;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
interface FlashcardViewerProps {
|
||||
cards: Card[];
|
||||
deckId: string;
|
||||
isShared?: boolean;
|
||||
storageNamespace?: string;
|
||||
initialProgress?: ProgressData | null;
|
||||
}
|
||||
|
||||
type CardResult = "correct" | "missed";
|
||||
|
||||
function initialFlashcardState(cards: Card[], progress?: ProgressData | null) {
|
||||
if (!progress) {
|
||||
return {
|
||||
order: cards.map((card) => card.id),
|
||||
currentIndex: 0,
|
||||
data: {} as Record<string, CardResult>,
|
||||
completed: cards.length === 0,
|
||||
wasRecovered: false,
|
||||
};
|
||||
}
|
||||
return normalizeProgress({
|
||||
orderJson: progress.orderJson,
|
||||
currentIndex: progress.currentIndex,
|
||||
dataJson: progress.cardResultsJson,
|
||||
liveIds: cards.map((card) => card.id),
|
||||
isValidValue: (value): value is CardResult =>
|
||||
value === "correct" || value === "missed",
|
||||
});
|
||||
}
|
||||
|
||||
export function FlashcardViewer({
|
||||
cards,
|
||||
deckId,
|
||||
isShared = false,
|
||||
storageNamespace,
|
||||
initialProgress,
|
||||
}: FlashcardViewerProps) {
|
||||
const sharedStorageKey = `flashcard_progress_${storageNamespace ?? "shared"}_${deckId}`;
|
||||
const [initialState] = useState(() => initialFlashcardState(cards, initialProgress));
|
||||
export function FlashcardViewer({ cards, deckId, isShared = false, initialProgress }: FlashcardViewerProps) {
|
||||
const [isLoaded, setIsLoaded] = useState(!isShared);
|
||||
|
||||
const [order, setOrder] = useState<string[]>(initialState.order);
|
||||
const [currentIndex, setCurrentIndex] = useState(initialState.currentIndex);
|
||||
const [order, setOrder] = useState<string[]>(
|
||||
initialProgress ? JSON.parse(initialProgress.orderJson) : cards.map((c) => c.id)
|
||||
);
|
||||
const [currentIndex, setCurrentIndex] = useState(
|
||||
initialProgress ? initialProgress.currentIndex : 0
|
||||
);
|
||||
const [isFlipped, setIsFlipped] = useState(false);
|
||||
const [hasFlippedOnce, setHasFlippedOnce] = useState(false);
|
||||
const [results, setResults] = useState<Record<string, CardResult>>(initialState.data);
|
||||
const [results, setResults] = useState<Record<string, CardResult>>(
|
||||
initialProgress && initialProgress.cardResultsJson
|
||||
? JSON.parse(initialProgress.cardResultsJson)
|
||||
: {}
|
||||
);
|
||||
const [isShuffled, setIsShuffled] = useState<boolean>(
|
||||
initialProgress ? initialProgress.mode === "SHUFFLED" : false
|
||||
);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
const [restoreWarning, setRestoreWarning] = useState(
|
||||
initialState.wasRecovered
|
||||
? "Some saved progress was invalid or referenced deleted cards, so it was safely repaired."
|
||||
: null
|
||||
);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
|
||||
// If progress is provided and index is already at or past the end, it means completed
|
||||
const [completed, setCompleted] = useState(initialState.completed);
|
||||
const [completed, setCompleted] = useState(
|
||||
initialProgress
|
||||
? initialProgress.currentIndex >= JSON.parse(initialProgress.orderJson).length
|
||||
: false
|
||||
);
|
||||
const [swipeClass, setSwipeClass] = useState("");
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const gradingRef = useRef(false);
|
||||
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const sessionIdRef = useRef(
|
||||
initialProgress?.sessionId ?? globalThis.crypto.randomUUID()
|
||||
);
|
||||
const revisionRef = useRef(initialProgress?.revision ?? 0);
|
||||
const saveQueueRef = useRef(Promise.resolve());
|
||||
|
||||
// Touch/drag state
|
||||
const dragRef = useRef({ startX: 0, currentX: 0, isDragging: false });
|
||||
|
|
@ -105,6 +73,7 @@ export function FlashcardViewer({
|
|||
const currentCard = order.length > 0 ? cards.find((c) => c.id === order[currentIndex]) : null;
|
||||
const correctCount = Object.values(results).filter((r) => r === "correct").length;
|
||||
const missedCount = Object.values(results).filter((r) => r === "missed").length;
|
||||
const totalGraded = correctCount + missedCount;
|
||||
|
||||
// Current run of consecutive correct cards, derived by walking back from the
|
||||
// current position. Deriving it (rather than storing it) keeps resumed
|
||||
|
|
@ -158,104 +127,29 @@ export function FlashcardViewer({
|
|||
|
||||
// Load from localStorage if shared
|
||||
useEffect(() => {
|
||||
if (!isShared) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
if (isShared) {
|
||||
try {
|
||||
const saved = localStorage.getItem(sharedStorageKey);
|
||||
const saved = localStorage.getItem(`flashcard_progress_${deckId}`);
|
||||
if (saved) {
|
||||
const parsed: unknown = JSON.parse(saved);
|
||||
const record =
|
||||
typeof parsed === "object" && parsed !== null
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
const restored = normalizeProgress({
|
||||
orderJson: record.order,
|
||||
currentIndex: record.currentIndex,
|
||||
dataJson: record.results,
|
||||
liveIds: cards.map((card) => card.id),
|
||||
isValidValue: (value): value is CardResult =>
|
||||
value === "correct" || value === "missed",
|
||||
});
|
||||
setOrder(restored.order);
|
||||
setCurrentIndex(restored.currentIndex);
|
||||
setResults(restored.data);
|
||||
setIsShuffled(record.mode === "SHUFFLED");
|
||||
setCompleted(restored.completed);
|
||||
if (restored.wasRecovered) {
|
||||
setRestoreWarning(
|
||||
"Some saved progress was invalid or referenced deleted cards, so it was safely repaired."
|
||||
);
|
||||
const parsed = JSON.parse(saved);
|
||||
setOrder(parsed.order || cards.map(c => c.id));
|
||||
setCurrentIndex(parsed.currentIndex || 0);
|
||||
setResults(parsed.results || {});
|
||||
setIsShuffled(parsed.mode === "SHUFFLED");
|
||||
|
||||
if (parsed.currentIndex >= (parsed.order?.length || cards.length)) {
|
||||
setCompleted(true);
|
||||
}
|
||||
setToastMessage("Session restored");
|
||||
}
|
||||
} catch {
|
||||
setRestoreWarning(
|
||||
"Saved progress could not be read. A fresh session was started safely."
|
||||
);
|
||||
} catch (e) {
|
||||
// Fallback to defaults
|
||||
}
|
||||
setIsLoaded(true);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isShared, sharedStorageKey, cards]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const saveProgress = useCallback(
|
||||
(
|
||||
orderArr: string[],
|
||||
index: number,
|
||||
cardResults: Record<string, CardResult>,
|
||||
mode: "SEQUENTIAL" | "SHUFFLED"
|
||||
) => {
|
||||
if (isShared) {
|
||||
localStorage.setItem(
|
||||
sharedStorageKey,
|
||||
JSON.stringify({
|
||||
mode,
|
||||
currentIndex: index,
|
||||
order: orderArr,
|
||||
results: cardResults,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const revision = ++revisionRef.current;
|
||||
saveQueueRef.current = saveQueueRef.current
|
||||
.then(async () => {
|
||||
const response = await fetch("/api/progress", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contentType: "DECK",
|
||||
contentId: deckId,
|
||||
mode,
|
||||
currentIndex: index,
|
||||
order: orderArr,
|
||||
cardResults,
|
||||
sessionId: sessionIdRef.current,
|
||||
revision,
|
||||
}),
|
||||
});
|
||||
if (response.status === 409) {
|
||||
setToastMessage("Progress changed in another session; reload to continue safely.");
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error("Progress save failed");
|
||||
})
|
||||
.catch(() => {
|
||||
setToastMessage("Progress could not be saved. Your current view is unchanged.");
|
||||
});
|
||||
},
|
||||
[deckId, isShared, sharedStorageKey]
|
||||
);
|
||||
}
|
||||
}, [isShared, deckId, cards]);
|
||||
|
||||
function toggleShuffle() {
|
||||
if (isTransitioning) return;
|
||||
const newShuffled = !isShuffled;
|
||||
setIsShuffled(newShuffled);
|
||||
|
||||
|
|
@ -281,7 +175,6 @@ export function FlashcardViewer({
|
|||
(grade: CardResult) => {
|
||||
if (!currentCard || !hasFlippedOnce || gradingRef.current) return;
|
||||
gradingRef.current = true;
|
||||
setIsTransitioning(true);
|
||||
|
||||
if (!isShared) {
|
||||
recordStudyActivity("FLASHCARD").catch(() => {});
|
||||
|
|
@ -294,17 +187,15 @@ export function FlashcardViewer({
|
|||
// Animate
|
||||
setSwipeClass(grade === "correct" ? "animate-swipe-right" : "animate-swipe-left");
|
||||
|
||||
transitionTimerRef.current = setTimeout(() => {
|
||||
setTimeout(() => {
|
||||
gradingRef.current = false;
|
||||
setIsTransitioning(false);
|
||||
setSwipeClass("");
|
||||
setIsFlipped(false);
|
||||
setHasFlippedOnce(false);
|
||||
|
||||
if (currentIndex + 1 >= order.length) {
|
||||
setCompleted(true);
|
||||
setCurrentIndex(order.length);
|
||||
saveProgress(order, order.length, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
|
||||
saveProgress(order, currentIndex, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
|
||||
} else {
|
||||
const nextIndex = currentIndex + 1;
|
||||
setCurrentIndex(nextIndex);
|
||||
|
|
@ -312,13 +203,13 @@ export function FlashcardViewer({
|
|||
}
|
||||
}, 350);
|
||||
},
|
||||
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled, isShared, saveProgress]
|
||||
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled, isShared]
|
||||
);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (completed || isTransitioning) return;
|
||||
if (completed) return;
|
||||
|
||||
if (e.key === " " || e.key === "Enter" || e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
|
|
@ -336,7 +227,7 @@ export function FlashcardViewer({
|
|||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [completed, isFlipped, gradeCard, hasFlippedOnce, isTransitioning]);
|
||||
}, [completed, isFlipped, gradeCard]);
|
||||
|
||||
// Touch handlers for swipe
|
||||
function handleTouchStart(e: React.TouchEvent) {
|
||||
|
|
@ -371,9 +262,39 @@ export function FlashcardViewer({
|
|||
}
|
||||
}
|
||||
|
||||
// Save progress (debounced / fire-and-forget)
|
||||
function saveProgress(
|
||||
orderArr: string[],
|
||||
index: number,
|
||||
cardResults: Record<string, CardResult>,
|
||||
m: "SEQUENTIAL" | "SHUFFLED"
|
||||
) {
|
||||
if (isShared) {
|
||||
localStorage.setItem(`flashcard_progress_${deckId}`, JSON.stringify({
|
||||
mode: m,
|
||||
currentIndex: index,
|
||||
order: orderArr,
|
||||
results: cardResults,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
fetch("/api/progress", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contentType: "DECK",
|
||||
contentId: deckId,
|
||||
mode: m,
|
||||
currentIndex: index,
|
||||
orderJson: JSON.stringify(orderArr),
|
||||
cardResultsJson: JSON.stringify(cardResults),
|
||||
}),
|
||||
}).catch(() => {}); // fire-and-forget
|
||||
}
|
||||
|
||||
// Restart handlers
|
||||
function restartFullSet() {
|
||||
if (isTransitioning) return;
|
||||
gradingRef.current = false;
|
||||
let newOrder: string[];
|
||||
if (isShuffled) {
|
||||
|
|
@ -391,7 +312,6 @@ export function FlashcardViewer({
|
|||
}
|
||||
|
||||
function redoMissed() {
|
||||
if (isTransitioning) return;
|
||||
const missedIds = Object.entries(results)
|
||||
.filter(([, r]) => r === "missed")
|
||||
.map(([id]) => id);
|
||||
|
|
@ -417,11 +337,6 @@ export function FlashcardViewer({
|
|||
if (completed) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
{restoreWarning && (
|
||||
<div className="mb-4 w-full max-w-md rounded-xl bg-badge-bg px-4 py-3 text-sm text-text-secondary" role="status">
|
||||
{restoreWarning}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full max-w-md rounded-3xl border border-border-light bg-bg-surface p-8 text-center shadow-[var(--shadow-card)]">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-success-bg mb-4">
|
||||
<svg className="w-7 h-7 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -478,11 +393,6 @@ export function FlashcardViewer({
|
|||
// Study view
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
{restoreWarning && (
|
||||
<div className="mb-4 w-full max-w-4xl rounded-xl bg-badge-bg px-4 py-3 text-sm text-text-secondary" role="status">
|
||||
{restoreWarning}
|
||||
</div>
|
||||
)}
|
||||
{/* Running tally */}
|
||||
<div className="mb-5 w-full max-w-4xl rounded-xl border border-border-light bg-bg-surface/70 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 text-sm">
|
||||
|
|
@ -511,7 +421,6 @@ export function FlashcardViewer({
|
|||
ref={cardRef}
|
||||
className={`perspective-1000 cursor-pointer select-none relative ${swipeClass}`}
|
||||
onClick={() => {
|
||||
if (isTransitioning) return;
|
||||
setToastMessage(null);
|
||||
setIsFlipped(!isFlipped);
|
||||
if (!isFlipped) setHasFlippedOnce(true);
|
||||
|
|
@ -597,8 +506,7 @@ export function FlashcardViewer({
|
|||
<div className="flex items-center gap-2 md:gap-4 animate-fade-in pointer-events-auto">
|
||||
<button
|
||||
onClick={() => gradeCard("missed")}
|
||||
disabled={isTransitioning}
|
||||
className="flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border-2 border-error/30 bg-bg-surface px-4 font-bold text-error transition-colors hover:bg-error-bg disabled:cursor-not-allowed disabled:opacity-50 sm:flex-none sm:px-7"
|
||||
className="flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border-2 border-error/30 bg-bg-surface px-4 font-bold text-error transition-colors hover:bg-error-bg sm:flex-none sm:px-7"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
|
|
@ -607,8 +515,7 @@ export function FlashcardViewer({
|
|||
</button>
|
||||
<button
|
||||
onClick={() => gradeCard("correct")}
|
||||
disabled={isTransitioning}
|
||||
className="flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border-2 border-success/30 bg-bg-surface px-4 font-bold text-success transition-colors hover:bg-success-bg disabled:cursor-not-allowed disabled:opacity-50 sm:flex-none sm:px-7"
|
||||
className="flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border-2 border-success/30 bg-bg-surface px-4 font-bold text-success transition-colors hover:bg-success-bg sm:flex-none sm:px-7"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
|
|
@ -624,7 +531,6 @@ export function FlashcardViewer({
|
|||
{currentIndex > 0 ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isTransitioning) return;
|
||||
const prevIndex = currentIndex - 1;
|
||||
setCurrentIndex(prevIndex);
|
||||
setIsFlipped(false);
|
||||
|
|
@ -636,7 +542,6 @@ export function FlashcardViewer({
|
|||
setResults(newResults);
|
||||
saveProgress(order, prevIndex, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
|
||||
}}
|
||||
disabled={isTransitioning}
|
||||
className="p-2 rounded-full text-text-muted hover:bg-bg-surface-alt hover:text-text-heading transition-all duration-200 cursor-pointer bg-bg-base/50"
|
||||
title="Previous Card"
|
||||
>
|
||||
|
|
@ -648,7 +553,6 @@ export function FlashcardViewer({
|
|||
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
disabled={isTransitioning}
|
||||
className={`p-2 rounded-full transition-all duration-200 cursor-pointer bg-bg-base/50 ${
|
||||
isShuffled
|
||||
? "text-primary hover:bg-primary/10"
|
||||
|
|
|
|||
|
|
@ -43,11 +43,9 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) {
|
|||
return;
|
||||
}
|
||||
|
||||
const incompleteRows = cards.flatMap((card, index) =>
|
||||
card.front.trim() && card.back.trim() ? [] : [index + 1]
|
||||
);
|
||||
if (incompleteRows.length > 0) {
|
||||
setError(`Complete or remove card row${incompleteRows.length === 1 ? "" : "s"}: ${incompleteRows.join(", ")}.`);
|
||||
const validCards = cards.filter((c) => c.front.trim() && c.back.trim());
|
||||
if (validCards.length === 0) {
|
||||
setError("Please add at least one complete card.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -63,7 +61,7 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) {
|
|||
type: "flashcards",
|
||||
deckName: deckName,
|
||||
description: description || undefined,
|
||||
cards: cards.map((c) => ({
|
||||
cards: validCards.map((c) => ({
|
||||
front: c.front,
|
||||
back: c.back,
|
||||
})),
|
||||
|
|
@ -72,11 +70,9 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) {
|
|||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const response = await res.json().catch(() => null) as { error?: string } | null;
|
||||
throw new Error(response?.error ?? "Failed to create deck");
|
||||
throw new Error("Failed to create deck");
|
||||
}
|
||||
|
||||
window.dispatchEvent(new Event("study-decks-changed"));
|
||||
onCreated();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create deck");
|
||||
|
|
|
|||
|
|
@ -1,40 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" }) {
|
||||
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" | "connections" | "crossword" }) {
|
||||
const [instructions, setInstructions] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveResult, setSaveResult] = useState<"success" | "error" | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [packCount, setPackCount] = useState(1);
|
||||
|
||||
const loadInstructions = useCallback(async (signal?: AbortSignal) => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/settings/llm-instructions?type=${importType}`, { signal });
|
||||
if (!response.ok) throw new Error("Instructions could not be loaded.");
|
||||
const data = await response.json() as { value?: unknown };
|
||||
if (typeof data.value !== "string") throw new Error("The server returned invalid instructions.");
|
||||
setInstructions(data.value);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) return;
|
||||
setLoadError(error instanceof Error ? error.message : "Instructions could not be loaded.");
|
||||
} finally {
|
||||
if (!signal?.aborted) setLoading(false);
|
||||
}
|
||||
}, [importType]);
|
||||
const batchOverride = importType === "connections" && packCount > 1
|
||||
? `TEMPORARY BATCH OVERRIDE:\nGenerate exactly ${packCount} distinct Connections packs. Return one raw JSON array containing exactly ${packCount} objects that each follow the schema below. Make the packs meaningfully different from one another. This override takes precedence over any later instruction to return one object.\n\n`
|
||||
: "";
|
||||
const displayedInstructions = `${batchOverride}${instructions}`;
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => void loadInstructions(controller.signal), 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [loadInstructions]);
|
||||
fetch(`/api/settings/llm-instructions?type=${importType}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => setInstructions(data.value))
|
||||
.finally(() => setLoading(false));
|
||||
}, [importType]);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
|
|
@ -64,51 +50,46 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
|
|||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ value: "__RESET__" }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to reset instructions");
|
||||
const data = await res.json();
|
||||
if (typeof data.value !== "string") throw new Error("Invalid reset response");
|
||||
setInstructions(data.value);
|
||||
setSaveResult("success");
|
||||
} catch {
|
||||
setSaveResult("error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(instructions);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
setLoadError("Clipboard access failed. Select and copy the instructions manually.");
|
||||
}
|
||||
await navigator.clipboard.writeText(displayedInstructions);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="animate-subtle-pulse text-text-muted">Loading instructions...</div>;
|
||||
}
|
||||
|
||||
if (loadError && !instructions) {
|
||||
return (
|
||||
<div role="alert" className="rounded-xl border border-error/30 bg-error-bg p-4 text-error">
|
||||
<p>{loadError}</p>
|
||||
<button onClick={() => void loadInstructions()} className="mt-3 min-h-10 rounded-lg border border-error/40 px-4 text-sm font-bold">Retry</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{loadError && <p role="alert" className="rounded-lg bg-error-bg p-3 text-sm text-error">{loadError}</p>}
|
||||
<p className="text-sm text-text-secondary">
|
||||
Copy these instructions and paste them into an LLM chat along with your study material. The LLM will generate JSON you can paste into the Import tab.
|
||||
</p>
|
||||
|
||||
{importType === "connections" && (
|
||||
<div className="rounded-xl border border-border bg-bg-surface-alt/60 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<label htmlFor="connections-pack-count" className="text-sm font-bold text-text-heading">Number of game packs</label>
|
||||
<p className="text-xs text-text-muted">This temporarily updates the copied prompt. Your saved instructions stay unchanged.</p>
|
||||
</div>
|
||||
<output htmlFor="connections-pack-count" className="grid h-11 min-w-12 place-items-center rounded-lg border border-border bg-bg-surface px-3 text-lg font-extrabold text-text-heading">{packCount}</output>
|
||||
</div>
|
||||
<input id="connections-pack-count" type="range" min={1} max={10} step={1} value={packCount} onChange={(event) => setPackCount(Number(event.target.value))} className="mt-4 w-full accent-primary" />
|
||||
<div className="mt-1 flex justify-between text-[10px] font-bold text-text-muted" aria-hidden><span>1</span><span>5</span><span>10</span></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={instructions}
|
||||
onChange={(event) => setInstructions(event.target.value)}
|
||||
value={displayedInstructions}
|
||||
onChange={(event) => setInstructions(batchOverride && event.target.value.startsWith(batchOverride) ? event.target.value.slice(batchOverride.length) : event.target.value)}
|
||||
rows={14}
|
||||
className="w-full px-4 py-3 rounded-lg border border-border bg-bg-surface-alt/50 text-sm text-text-body font-mono leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200 resize-y"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -134,9 +134,6 @@ export function ImportTab({ classId, importType, onImported }: ImportTabProps) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (importType === "flashcards") {
|
||||
window.dispatchEvent(new Event("study-decks-changed"));
|
||||
}
|
||||
onImported();
|
||||
} catch {
|
||||
setError("Network error during import");
|
||||
|
|
@ -173,12 +170,12 @@ export function ImportTab({ classId, importType, onImported }: ImportTabProps) {
|
|||
onChange={(e) => setSelectedGroupId(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200"
|
||||
>
|
||||
<option value="">Uncategorized</option>
|
||||
{materialGroups.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="">Uncategorized</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import remarkGfm from "remark-gfm";
|
|||
import { CategoryBreakdown } from "./CategoryBreakdown";
|
||||
import { scoreQuestion } from "@/lib/scoring";
|
||||
import type { QuizAttempt, QuizQuestion, QuizSummary } from "@/types/study";
|
||||
import { parseQuizReviewSnapshot } from "@/lib/quizSnapshots";
|
||||
|
||||
interface QuizResultsProps {
|
||||
quiz: QuizSummary;
|
||||
|
|
@ -21,28 +20,17 @@ interface ReviewItem {
|
|||
}
|
||||
|
||||
export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsProps) {
|
||||
const snapshot = parseQuizReviewSnapshot(attempt.reviewSnapshotJson);
|
||||
let answers: Record<string, string[]> = {};
|
||||
try {
|
||||
answers = JSON.parse(attempt.answersJson);
|
||||
} catch {}
|
||||
|
||||
if (snapshot) {
|
||||
answers = Object.fromEntries(
|
||||
snapshot.questions.map((question) => [question.id, question.selections])
|
||||
);
|
||||
}
|
||||
|
||||
// Determine non-full-credit questions for review list
|
||||
const reviewItems: ReviewItem[] = [];
|
||||
const missedIds: string[] = [];
|
||||
|
||||
const answeredIds = Object.keys(answers);
|
||||
const questionsToReview = snapshot?.questions ??
|
||||
quiz.questions.filter((q) => answeredIds.includes(q.id));
|
||||
const snapshotPoints = new Map(
|
||||
snapshot?.questions.map((question) => [question.id, question.points]) ?? []
|
||||
);
|
||||
const questionsToReview = quiz.questions.filter((q) => answeredIds.includes(q.id));
|
||||
|
||||
questionsToReview.forEach((q) => {
|
||||
const formattedQ = {
|
||||
|
|
@ -50,8 +38,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
type: q.type as "MULTIPLE_CHOICE" | "SATA",
|
||||
options: q.options,
|
||||
};
|
||||
const score = snapshotPoints.get(q.id) ??
|
||||
scoreQuestion(formattedQ, answers[q.id] || []);
|
||||
const score = scoreQuestion(formattedQ, answers[q.id] || []);
|
||||
|
||||
// If not full credit (1.0), add to review and missed arrays
|
||||
if (score < 1) {
|
||||
|
|
@ -63,9 +50,6 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
});
|
||||
}
|
||||
});
|
||||
const liveQuestionIds = new Set(quiz.questions.map((question) => question.id));
|
||||
const retakeableMissedIds = missedIds.filter((id) => liveQuestionIds.has(id));
|
||||
const reviewQuiz = { ...quiz, questions: questionsToReview };
|
||||
|
||||
const percentage = attempt.maxScore > 0 ? (attempt.score / attempt.maxScore) * 100 : 0;
|
||||
|
||||
|
|
@ -104,12 +88,12 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
</button>
|
||||
)}
|
||||
|
||||
{retakeableMissedIds.length > 0 && (
|
||||
{missedIds.length > 0 && (
|
||||
<button
|
||||
onClick={() => onRetake(retakeableMissedIds)}
|
||||
onClick={() => onRetake(missedIds)}
|
||||
className="px-6 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
Retake Missed ({retakeableMissedIds.length})
|
||||
Retake Missed ({missedIds.length})
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
|
@ -128,7 +112,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
{/* Category Breakdown */}
|
||||
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-6 md:p-8">
|
||||
<h3 className="text-lg font-bold text-text-heading mb-6">Category Breakdown</h3>
|
||||
<CategoryBreakdown quiz={reviewQuiz} answeredIds={answeredIds} answers={answers} />
|
||||
<CategoryBreakdown quiz={quiz} answeredIds={answeredIds} answers={answers} />
|
||||
</div>
|
||||
|
||||
{/* Review List */}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { scoreQuestion } from "@/lib/scoring";
|
|||
import { QuizResults } from "./QuizResults";
|
||||
import { recordStudyActivity } from "@/lib/activityClient";
|
||||
import type { QuizAttempt } from "@/types/study";
|
||||
import { normalizeProgress } from "@/lib/progressNormalization";
|
||||
|
||||
interface Option {
|
||||
id: string;
|
||||
|
|
@ -34,39 +33,16 @@ interface QuizViewerProps {
|
|||
currentIndex: number;
|
||||
orderJson: string;
|
||||
answersJson: string | null;
|
||||
sessionId: string;
|
||||
revision: number;
|
||||
}>;
|
||||
};
|
||||
retakeIds: string[] | null;
|
||||
sessionKey?: string | number;
|
||||
storageNamespace?: string;
|
||||
isShared?: boolean;
|
||||
onFinished?: () => void;
|
||||
}
|
||||
|
||||
export function QuizViewer({
|
||||
quiz,
|
||||
retakeIds,
|
||||
sessionKey = "default",
|
||||
storageNamespace,
|
||||
isShared = false,
|
||||
onFinished,
|
||||
}: QuizViewerProps) {
|
||||
export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: QuizViewerProps) {
|
||||
const submittingQuestionRef = useRef<string | null>(null);
|
||||
const finishingRef = useRef(false);
|
||||
const [initialQuiz] = useState(() => quiz);
|
||||
const [initialRetakeIds] = useState(() => retakeIds);
|
||||
const [initialProgress] = useState(() =>
|
||||
quiz.progress?.find((progress) => progress.mode === "SEQUENTIAL")
|
||||
);
|
||||
const progressSessionIdRef = useRef(
|
||||
initialProgress?.sessionId ?? globalThis.crypto.randomUUID()
|
||||
);
|
||||
const progressRevisionRef = useRef(initialProgress?.revision ?? 0);
|
||||
const saveQueueRef = useRef(Promise.resolve());
|
||||
const [order, setOrder] = useState<string[]>([]);
|
||||
const [attemptScope, setAttemptScope] = useState<string[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState<Record<string, string[]>>({});
|
||||
const [submittedAnswers, setSubmittedAnswers] = useState<string[]>([]);
|
||||
|
|
@ -75,11 +51,6 @@ export function QuizViewer({
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [resultsData, setResultsData] = useState<QuizAttempt | null>(null);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
const [isFinishing, setIsFinishing] = useState(false);
|
||||
const [finishError, setFinishError] = useState<string | null>(null);
|
||||
const [restoreWarning, setRestoreWarning] = useState<string | null>(null);
|
||||
const sessionIdentity = `${quiz.id}:${sessionKey}`;
|
||||
const sharedStorageKey = `quiz_progress_${storageNamespace ?? "shared"}_${quiz.id}`;
|
||||
|
||||
// Auto-hide toast
|
||||
useEffect(() => {
|
||||
|
|
@ -91,18 +62,16 @@ export function QuizViewer({
|
|||
|
||||
// Initialize session
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
// Generate shuffled options once per session mount
|
||||
const newShuffledOpts: Record<string, Option[]> = {};
|
||||
for (const q of initialQuiz.questions) {
|
||||
for (const q of quiz.questions) {
|
||||
newShuffledOpts[q.id] = [...q.options].sort(() => Math.random() - 0.5);
|
||||
}
|
||||
setShuffledOptions(newShuffledOpts);
|
||||
|
||||
// If retaking specific questions
|
||||
if (initialRetakeIds && initialRetakeIds.length > 0) {
|
||||
setOrder(initialRetakeIds);
|
||||
setAttemptScope(initialRetakeIds);
|
||||
if (retakeIds && retakeIds.length > 0) {
|
||||
setOrder(retakeIds);
|
||||
setCurrentIndex(0);
|
||||
setAnswers({});
|
||||
setSubmittedAnswers([]);
|
||||
|
|
@ -113,120 +82,62 @@ export function QuizViewer({
|
|||
// Shared links: load from localStorage
|
||||
if (isShared) {
|
||||
try {
|
||||
const saved = localStorage.getItem(sharedStorageKey);
|
||||
const saved = localStorage.getItem(`quiz_progress_${quiz.id}`);
|
||||
if (saved) {
|
||||
const parsed: unknown = JSON.parse(saved);
|
||||
const record =
|
||||
typeof parsed === "object" && parsed !== null
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
const liveIds = initialQuiz.questions.map((question) => question.id);
|
||||
const optionsByQuestion = new Map(
|
||||
initialQuiz.questions.map((question) => [
|
||||
question.id,
|
||||
new Set(question.options.map((option) => option.id)),
|
||||
])
|
||||
);
|
||||
const restored = normalizeProgress({
|
||||
orderJson: record.order,
|
||||
currentIndex: record.currentIndex,
|
||||
dataJson: record.answers,
|
||||
liveIds,
|
||||
isValidValue: (value, questionId): value is string[] =>
|
||||
Array.isArray(value) &&
|
||||
new Set(value).size === value.length &&
|
||||
value.every(
|
||||
(optionId) =>
|
||||
typeof optionId === "string" &&
|
||||
optionsByQuestion.get(questionId)?.has(optionId) === true
|
||||
),
|
||||
});
|
||||
const restoredSet = new Set(restored.order);
|
||||
const restoredOrder = [
|
||||
...restored.order,
|
||||
...liveIds.filter((id) => !restoredSet.has(id)),
|
||||
];
|
||||
setOrder(restoredOrder);
|
||||
setAttemptScope(restoredOrder);
|
||||
setCurrentIndex(
|
||||
restoredOrder.length === 0
|
||||
? 0
|
||||
: Math.min(restored.currentIndex, restoredOrder.length - 1)
|
||||
);
|
||||
setAnswers(restored.data);
|
||||
setSubmittedAnswers(Object.keys(restored.data));
|
||||
if (restored.wasRecovered || restoredOrder.length !== restored.order.length) {
|
||||
setRestoreWarning(
|
||||
"Some saved quiz progress was invalid or referenced changed questions, so it was safely repaired."
|
||||
);
|
||||
}
|
||||
const parsed = JSON.parse(saved);
|
||||
setOrder(parsed.order || quiz.questions.map(q => q.id));
|
||||
setCurrentIndex(parsed.currentIndex || 0);
|
||||
setAnswers(parsed.answers || {});
|
||||
setSubmittedAnswers(Object.keys(parsed.answers || {}));
|
||||
setToastMessage("Session restored");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
// Fallback to defaults
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, normal sequential logic checking for progress
|
||||
const prog = initialProgress;
|
||||
const prog = quiz.progress?.find((p) => p.mode === "SEQUENTIAL");
|
||||
if (prog) {
|
||||
const restored = normalizeProgress({
|
||||
orderJson: prog.orderJson,
|
||||
currentIndex: prog.currentIndex,
|
||||
dataJson: prog.answersJson,
|
||||
liveIds: initialQuiz.questions.map((question) => question.id),
|
||||
isValidValue: (value, questionId): value is string[] => {
|
||||
const question = initialQuiz.questions.find((item) => item.id === questionId);
|
||||
const optionIds = new Set(question?.options.map((option) => option.id) ?? []);
|
||||
return Array.isArray(value) &&
|
||||
new Set(value).size === value.length &&
|
||||
value.every((id) => typeof id === "string" && optionIds.has(id));
|
||||
},
|
||||
});
|
||||
const liveIds = initialQuiz.questions.map((question) => question.id);
|
||||
const restoredSet = new Set(restored.order);
|
||||
const restoredOrder = [
|
||||
...restored.order,
|
||||
...liveIds.filter((id) => !restoredSet.has(id)),
|
||||
];
|
||||
setOrder(restoredOrder);
|
||||
setAttemptScope(restoredOrder);
|
||||
setCurrentIndex(
|
||||
restoredOrder.length === 0
|
||||
? 0
|
||||
: Math.min(restored.currentIndex, restoredOrder.length - 1)
|
||||
);
|
||||
setAnswers(restored.data);
|
||||
setSubmittedAnswers(Object.keys(restored.data));
|
||||
if (restored.wasRecovered || restoredOrder.length !== restored.order.length) {
|
||||
setRestoreWarning(
|
||||
"Some saved quiz progress was invalid or referenced deleted questions, so it was safely repaired."
|
||||
);
|
||||
let savedOrder: string[] = [];
|
||||
try {
|
||||
savedOrder = JSON.parse(prog.orderJson);
|
||||
} catch {
|
||||
savedOrder = quiz.questions.map((q) => q.id);
|
||||
}
|
||||
|
||||
let savedAnswers: Record<string, string[]> = {};
|
||||
if (prog.answersJson) {
|
||||
try {
|
||||
savedAnswers = JSON.parse(prog.answersJson);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
setOrder(savedOrder);
|
||||
setCurrentIndex(Math.min(prog.currentIndex, savedOrder.length - 1));
|
||||
setAnswers(savedAnswers);
|
||||
setSubmittedAnswers(Object.keys(savedAnswers));
|
||||
} else {
|
||||
const freshOrder = initialQuiz.questions.map((q) => q.id).sort(() => Math.random() - 0.5);
|
||||
const freshOrder = quiz.questions.map((q) => q.id).sort(() => Math.random() - 0.5);
|
||||
setOrder(freshOrder);
|
||||
setAttemptScope(freshOrder);
|
||||
setCurrentIndex(0);
|
||||
setAnswers({});
|
||||
setSubmittedAnswers([]);
|
||||
}
|
||||
setLoading(false);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [sessionIdentity, isShared, sharedStorageKey, initialQuiz, initialRetakeIds, initialProgress]);
|
||||
}, [quiz, retakeIds, isShared]);
|
||||
|
||||
// Persist progress as you go
|
||||
function saveProgress(
|
||||
idx: number,
|
||||
ans: Record<string, string[]>
|
||||
) {
|
||||
if (attemptScope.length !== quiz.questions.length) return;
|
||||
if (retakeIds) return; // Don't persist retake partial state
|
||||
|
||||
if (isShared) {
|
||||
localStorage.setItem(sharedStorageKey, JSON.stringify({
|
||||
localStorage.setItem(`quiz_progress_${quiz.id}`, JSON.stringify({
|
||||
currentIndex: idx,
|
||||
order: order,
|
||||
answers: ans,
|
||||
|
|
@ -234,32 +145,18 @@ export function QuizViewer({
|
|||
return;
|
||||
}
|
||||
|
||||
const revision = ++progressRevisionRef.current;
|
||||
saveQueueRef.current = saveQueueRef.current
|
||||
.then(async () => {
|
||||
const response = await fetch("/api/progress", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contentType: "QUIZ",
|
||||
contentId: quiz.id,
|
||||
mode: "SEQUENTIAL",
|
||||
currentIndex: idx,
|
||||
order,
|
||||
answers: ans,
|
||||
sessionId: progressSessionIdRef.current,
|
||||
revision,
|
||||
}),
|
||||
});
|
||||
if (response.status === 409) {
|
||||
setToastMessage("Progress changed in another session; reload to continue safely.");
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error("Progress save failed");
|
||||
})
|
||||
.catch(() => {
|
||||
setToastMessage("Progress could not be saved. Your answers remain on screen.");
|
||||
});
|
||||
fetch("/api/progress", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contentType: "QUIZ",
|
||||
contentId: quiz.id,
|
||||
mode: "SEQUENTIAL",
|
||||
currentIndex: idx,
|
||||
orderJson: JSON.stringify(order),
|
||||
answersJson: JSON.stringify(ans),
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
if (loading) return <div>Loading quiz...</div>;
|
||||
|
|
@ -320,21 +217,18 @@ export function QuizViewer({
|
|||
}
|
||||
|
||||
async function handleFinish() {
|
||||
if (finishingRef.current) return;
|
||||
finishingRef.current = true;
|
||||
setIsFinishing(true);
|
||||
setFinishError(null);
|
||||
if (isShared) {
|
||||
localStorage.removeItem(sharedStorageKey);
|
||||
localStorage.removeItem(`quiz_progress_${quiz.id}`);
|
||||
}
|
||||
|
||||
// If shared, grade it locally and show results, don't ping backend
|
||||
if (isShared) {
|
||||
// Simulate an attempt object
|
||||
let runningScore = 0;
|
||||
const maxScore = attemptScope.length;
|
||||
let maxScore = quiz.questions.length; // Actually, depends on retakeIds, but shared doesn't support retakeIds typically
|
||||
if (retakeIds) maxScore = retakeIds.length;
|
||||
|
||||
const scoredItems = attemptScope.map(qId => {
|
||||
const scoredItems = (retakeIds || quiz.questions.map(q => q.id)).map(qId => {
|
||||
const q = quiz.questions.find((x) => x.id === qId);
|
||||
if (!q) return 0;
|
||||
const formattedQ = {
|
||||
|
|
@ -351,40 +245,45 @@ export function QuizViewer({
|
|||
score: runningScore,
|
||||
maxScore: maxScore,
|
||||
answersJson: JSON.stringify(answers),
|
||||
isPartialRetake: attemptScope.length !== quiz.questions.length,
|
||||
isPartialRetake: false,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
setResultsData(attempt);
|
||||
finishingRef.current = false;
|
||||
setIsFinishing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send to backend
|
||||
const isPartialRetake = !!retakeIds;
|
||||
try {
|
||||
await saveQueueRef.current;
|
||||
const res = await fetch(`/api/quizzes/${quiz.id}/attempt`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
questionIds: attemptScope,
|
||||
answers,
|
||||
answersJson: JSON.stringify(answers),
|
||||
isPartialRetake,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const payload = (await res.json().catch(() => null)) as
|
||||
| { error?: string }
|
||||
| null;
|
||||
throw new Error(payload?.error ?? "Could not save this attempt");
|
||||
if (res.ok) {
|
||||
const attempt = await res.json();
|
||||
|
||||
// Clear progress
|
||||
if (!isPartialRetake) {
|
||||
await fetch("/api/progress", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contentType: "QUIZ",
|
||||
contentId: quiz.id,
|
||||
mode: "SEQUENTIAL",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
setResultsData(attempt);
|
||||
}
|
||||
setResultsData(await res.json());
|
||||
} catch (error) {
|
||||
setFinishError(
|
||||
error instanceof Error ? error.message : "Could not save this attempt"
|
||||
);
|
||||
} finally {
|
||||
finishingRef.current = false;
|
||||
setIsFinishing(false);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -396,13 +295,11 @@ export function QuizViewer({
|
|||
onRetake={(missedIds: string[]) => {
|
||||
// Restart this component with retakeIds
|
||||
setOrder(missedIds);
|
||||
setAttemptScope(missedIds);
|
||||
setCurrentIndex(0);
|
||||
setAnswers({});
|
||||
setSubmittedAnswers([]);
|
||||
submittingQuestionRef.current = null;
|
||||
setResultsData(null);
|
||||
setFinishError(null);
|
||||
|
||||
// Re-shuffle options for the new attempt
|
||||
const newShuffledOpts: Record<string, Option[]> = {};
|
||||
|
|
@ -422,11 +319,6 @@ export function QuizViewer({
|
|||
|
||||
return (
|
||||
<div className="flex flex-col items-center relative">
|
||||
{restoreWarning && (
|
||||
<div className="mb-4 w-full max-w-4xl rounded-xl bg-badge-bg px-4 py-3 text-sm text-text-secondary" role="status">
|
||||
{restoreWarning}
|
||||
</div>
|
||||
)}
|
||||
{/* Toast Notification */}
|
||||
{toastMessage && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-30 animate-fade-in pointer-events-none">
|
||||
|
|
@ -612,20 +504,12 @@ export function QuizViewer({
|
|||
Next Question
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
{finishError && (
|
||||
<div className="mb-3 rounded-xl bg-error-bg px-4 py-3 text-sm text-error" role="alert">
|
||||
{finishError} Your answers are still here; try again.
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleFinish}
|
||||
disabled={isFinishing}
|
||||
className="w-full py-3.5 rounded-xl bg-success hover:bg-success/90 text-white font-bold text-lg tracking-wide transition-all duration-200 cursor-pointer shadow-sm disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isFinishing ? "Saving attempt..." : "Finish Quiz"}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleFinish}
|
||||
className="w-full py-3.5 rounded-xl bg-success hover:bg-success/90 text-white font-bold text-lg tracking-wide transition-all duration-200 cursor-pointer shadow-sm"
|
||||
>
|
||||
Finish Quiz
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -188,16 +188,7 @@ export function SpacedRepetitionSets() {
|
|||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => void load(), 0);
|
||||
function refreshWhenVisible() {
|
||||
if (document.visibilityState === "visible") void load();
|
||||
}
|
||||
window.addEventListener("focus", refreshWhenVisible);
|
||||
window.addEventListener("study-decks-changed", refreshWhenVisible);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener("focus", refreshWhenVisible);
|
||||
window.removeEventListener("study-decks-changed", refreshWhenVisible);
|
||||
};
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
|
||||
async function createSet() {
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@ export function Navbar() {
|
|||
const parts = pathname.split("/").filter(Boolean);
|
||||
const classSlug = parts[0] && parts[0] !== "shared" ? parts[0] : null;
|
||||
const [hasReadyCards, setHasReadyCards] = useState(false);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
const [logoutError, setLogoutError] = useState<string | null>(null);
|
||||
|
||||
const refreshDueCards = useCallback(async () => {
|
||||
if (!classSlug) return;
|
||||
|
|
@ -55,18 +53,9 @@ export function Navbar() {
|
|||
}, [refreshDueCards]);
|
||||
|
||||
async function handleLogout() {
|
||||
setLoggingOut(true);
|
||||
setLogoutError(null);
|
||||
try {
|
||||
const response = await fetch("/api/auth/logout", { method: "POST" });
|
||||
if (!response.ok) throw new Error("Sign out failed. Your session is still active.");
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setLogoutError(error instanceof Error ? error.message : "Sign out failed. Please retry.");
|
||||
} finally {
|
||||
setLoggingOut(false);
|
||||
}
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const nav = (
|
||||
|
|
@ -121,8 +110,7 @@ export function Navbar() {
|
|||
<span className="text-xs font-semibold text-text-muted">Appearance</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
{logoutError && <p role="alert" className="mt-2 rounded-lg bg-error-bg p-2 text-xs text-error">{logoutError}</p>}
|
||||
<button aria-label={loggingOut ? "Signing out" : logoutError ? "Retry sign out" : "Sign out"} disabled={loggingOut} onClick={handleLogout} className="mt-1 flex min-h-11 w-full items-center gap-3 rounded-xl px-3 text-left text-sm font-semibold text-text-secondary transition-colors hover:bg-error-bg hover:text-error disabled:opacity-50">
|
||||
<button onClick={handleLogout} className="mt-1 flex min-h-11 w-full items-center gap-3 rounded-xl px-3 text-left text-sm font-semibold text-text-secondary transition-colors hover:bg-error-bg hover:text-error">
|
||||
<span aria-hidden>↗</span> Sign out
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue