Compare commits
9 commits
testbranch
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bd0a9aad4 | |||
| e8dffc9692 | |||
| 5db7fc8afd | |||
| 931e674814 | |||
| a95f7c1d18 | |||
| e86b0b21df | |||
| 089439ed90 | |||
| faaccf8a7e | |||
| c3c26718f9 |
196 changed files with 8342 additions and 10899 deletions
20
.dockerignore
Normal file
20
.dockerignore
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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: Automated Container Build
|
name: Verify and publish container
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|
@ -11,15 +11,60 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Log into Local Registry
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.FORGEJO_PAT }}" | docker login git.elijahkuntz.com -u "${{ gitea.actor }}" --password-stdin
|
echo "${{ secrets.FORGEJO_PAT }}" | docker login git.elijahkuntz.com -u "${{ gitea.actor }}" --password-stdin
|
||||||
|
|
||||||
- name: Build and Push Image
|
- name: Install locked dependencies
|
||||||
run: |
|
run: npm ci
|
||||||
# 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" .
|
- name: Run tests
|
||||||
docker push "$IMAGE_PATH"
|
run: npm test
|
||||||
|
|
||||||
|
- name: Validate Prisma schema and migration drift
|
||||||
|
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"
|
||||||
|
|
|
||||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# 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,12 +12,18 @@
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
/.test-databases/
|
||||||
|
|
||||||
# local SQLite study data
|
# local SQLite study data
|
||||||
/dev.db
|
/dev.db
|
||||||
/dev.db-journal
|
/dev.db-journal
|
||||||
/dev.db-shm
|
/dev.db-shm
|
||||||
/dev.db-wal
|
/dev.db-wal
|
||||||
|
/data/
|
||||||
|
/study.db
|
||||||
|
/study.db-journal
|
||||||
|
/study.db-shm
|
||||||
|
/study.db-wal
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
|
|
|
||||||
23
Dockerfile
23
Dockerfile
|
|
@ -1,11 +1,19 @@
|
||||||
|
# ---- base ----
|
||||||
|
FROM node:22-slim AS base
|
||||||
|
RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# ---- deps ----
|
# ---- deps ----
|
||||||
FROM node:22-slim AS deps
|
FROM base AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
|
|
||||||
|
# ---- production dependencies ----
|
||||||
|
FROM deps AS runtime-deps
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
# ---- builder ----
|
# ---- builder ----
|
||||||
FROM node:22-slim AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
@ -13,13 +21,13 @@ RUN npx prisma generate
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ---- runner ----
|
# ---- runner ----
|
||||||
FROM node:22-slim AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3726
|
ENV PORT=3726
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
ENV DATABASE_URL="file:/app/data/study.db"
|
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
|
RUN useradd --system --create-home appuser && mkdir -p /app/data && chown -R appuser:appuser /app
|
||||||
|
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
|
@ -27,10 +35,13 @@ COPY --from=builder /app/.next/static ./.next/static
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder /app/prisma ./prisma
|
COPY --from=builder /app/prisma ./prisma
|
||||||
COPY --from=builder /app/prisma.config.ts ./
|
COPY --from=builder /app/prisma.config.ts ./
|
||||||
RUN npm install prisma@^7.8.0
|
COPY --from=builder /app/scripts ./scripts
|
||||||
|
COPY --from=runtime-deps /app/node_modules ./node_modules
|
||||||
COPY docker-entrypoint.sh ./
|
COPY docker-entrypoint.sh ./
|
||||||
RUN chmod +x docker-entrypoint.sh
|
RUN chmod +x docker-entrypoint.sh
|
||||||
# Running as root to avoid permission denied on Unraid host-mounted volumes
|
# Running as root to avoid permission denied on Unraid host-mounted volumes
|
||||||
EXPOSE 3726
|
EXPOSE 3726
|
||||||
VOLUME ["/app/data"]
|
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"]
|
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||||
|
|
|
||||||
81
README.md
81
README.md
|
|
@ -1,4 +1,83 @@
|
||||||
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).
|
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.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
|
|
|
||||||
67
audit-results/CONFIRMED_FINDINGS.md
Normal file
67
audit-results/CONFIRMED_FINDINGS.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# 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).
|
||||||
38
audit-results/COVERAGE_MAP.md
Normal file
38
audit-results/COVERAGE_MAP.md
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# 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).
|
||||||
69
audit-results/EXECUTIVE_SUMMARY.md
Normal file
69
audit-results/EXECUTIVE_SUMMARY.md
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# 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.
|
||||||
1024
audit-results/FINDINGS.json
Normal file
1024
audit-results/FINDINGS.json
Normal file
File diff suppressed because it is too large
Load diff
15
audit-results/REJECTED_FINDINGS.md
Normal file
15
audit-results/REJECTED_FINDINGS.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# 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. |
|
||||||
340
audit-results/REMEDIATION_PLAN.md
Normal file
340
audit-results/REMEDIATION_PLAN.md
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
# 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.
|
||||||
20
audit-results/SUBAGENT_REPORTS.md
Normal file
20
audit-results/SUBAGENT_REPORTS.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# 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`.
|
||||||
21
audit-results/TEST_GAPS.md
Normal file
21
audit-results/TEST_GAPS.md
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# 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.
|
||||||
35
audit-results/UNVERIFIED_RISKS.md
Normal file
35
audit-results/UNVERIFIED_RISKS.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# 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).
|
||||||
38
audit-results/VERIFICATION_LOG.md
Normal file
38
audit-results/VERIFICATION_LOG.md
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# 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.
|
||||||
BIN
audit-results/tmp/audit-migration.db
Normal file
BIN
audit-results/tmp/audit-migration.db
Normal file
Binary file not shown.
26
audit-results/tmp/generated-before.sha256
Normal file
26
audit-results/tmp/generated-before.sha256
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
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
|
||||||
52
audit-results/tmp/inspect-db.cjs
Normal file
52
audit-results/tmp/inspect-db.cjs
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
// 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");
|
||||||
40
audit-results/tmp/reproduce-drift.cjs
Normal file
40
audit-results/tmp/reproduce-drift.cjs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
// 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();
|
||||||
27
audit-results/tmp/validate-findings.cjs
Normal file
27
audit-results/tmp/validate-findings.cjs
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
// 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(", "));
|
||||||
145
benchmarks/remediationBenchmark.test.ts
Normal file
145
benchmarks/remediationBenchmark.test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
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,12 +1,15 @@
|
||||||
services:
|
services:
|
||||||
study-app:
|
study-app:
|
||||||
build:
|
build:
|
||||||
|
context: .
|
||||||
target: deps
|
target: deps
|
||||||
command: npm run dev
|
command: npm run dev
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=file:/app/data/study.db
|
||||||
|
- NODE_ENV=development
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
environment:
|
- ./data:/app/data
|
||||||
- NODE_ENV=development
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
|
|
@ -4,11 +4,13 @@ services:
|
||||||
container_name: study-app
|
container_name: study-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3726"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=file:/app/data/study.db
|
- DATABASE_URL=file:/app/data/study.db
|
||||||
- SESSION_SECRET=${SESSION_SECRET}
|
- SESSION_SECRET=${SESSION_SECRET:?SESSION_SECRET must be set to at least 32 characters}
|
||||||
- ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH}
|
- ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH:-}
|
||||||
|
- ALLOW_INITIAL_SETUP=${ALLOW_INITIAL_SETUP:-false}
|
||||||
|
- SECURE_COOKIES=${SECURE_COOKIES:-false}
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,13 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
npx prisma migrate deploy
|
|
||||||
|
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
|
||||||
exec node server.js
|
exec node server.js
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ const eslintConfig = defineConfig([
|
||||||
"out/**",
|
"out/**",
|
||||||
"build/**",
|
"build/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
"audit-results/tmp/**",
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
1
out.css
1
out.css
|
|
@ -1 +0,0 @@
|
||||||
:root { --color-primary: #fff; }
|
|
||||||
625
package-lock.json
generated
625
package-lock.json
generated
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
|
@ -8,7 +8,11 @@
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"test": "vitest run"
|
"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"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|
@ -20,7 +24,8 @@
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"iron-session": "^8.0.4",
|
"iron-session": "^8.0.4",
|
||||||
"jsonrepair": "^3.14.1",
|
"jsonrepair": "^3.14.1",
|
||||||
"next": "16.2.9",
|
"next": "16.3.0",
|
||||||
|
"prisma": "^7.8.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|
@ -30,12 +35,12 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/better-sqlite3": "^9.6.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.9",
|
"eslint-config-next": "16.2.9",
|
||||||
"prisma": "^7.8.0",
|
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "QuizAttempt" ADD COLUMN "reviewSnapshotJson" TEXT;
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "StudyProgress" ADD COLUMN "sessionId" TEXT NOT NULL DEFAULT 'legacy';
|
||||||
|
ALTER TABLE "StudyProgress" ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 0;
|
||||||
10
prisma/migrations/20260808100000_remove_arcade/migration.sql
Normal file
10
prisma/migrations/20260808100000_remove_arcade/migration.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- 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,7 +18,6 @@ model Class {
|
||||||
quizSets QuizSet[]
|
quizSets QuizSet[]
|
||||||
materialGroups MaterialGroup[]
|
materialGroups MaterialGroup[]
|
||||||
spacedRepetitionSets SpacedRepetitionSet[]
|
spacedRepetitionSets SpacedRepetitionSet[]
|
||||||
arcadePacks ArcadePack[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Deck {
|
model Deck {
|
||||||
|
|
@ -99,6 +98,8 @@ model StudyProgress {
|
||||||
orderJson String // JSON array of card/question ids
|
orderJson String // JSON array of card/question ids
|
||||||
answersJson String? // in-progress quiz answers
|
answersJson String? // in-progress quiz answers
|
||||||
cardResultsJson String? // per-card grades for decks
|
cardResultsJson String? // per-card grades for decks
|
||||||
|
sessionId String @default("legacy")
|
||||||
|
revision Int @default(0)
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
deck Deck? @relation(fields: [deckId], references: [id], onDelete: Cascade)
|
deck Deck? @relation(fields: [deckId], references: [id], onDelete: Cascade)
|
||||||
|
|
@ -114,6 +115,7 @@ model QuizAttempt {
|
||||||
score Float
|
score Float
|
||||||
maxScore Int
|
maxScore Int
|
||||||
answersJson String
|
answersJson String
|
||||||
|
reviewSnapshotJson String?
|
||||||
isPartialRetake Boolean @default(false)
|
isPartialRetake Boolean @default(false)
|
||||||
completedAt DateTime @default(now())
|
completedAt DateTime @default(now())
|
||||||
|
|
||||||
|
|
@ -147,52 +149,12 @@ model Setting {
|
||||||
|
|
||||||
model StudyActivity {
|
model StudyActivity {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
type String // "FLASHCARD" | "QUIZ_QUESTION" | "ARCADE_GROUP"
|
type String // "FLASHCARD" | "QUIZ_QUESTION"
|
||||||
occurredAt DateTime @default(now())
|
occurredAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([occurredAt])
|
@@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 {
|
model MaterialGroup {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
classId String
|
classId String
|
||||||
|
|
|
||||||
42
scripts/backup-database.mjs
Normal file
42
scripts/backup-database.mjs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
10
scripts/databasePath.mjs
Normal file
10
scripts/databasePath.mjs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
358
scripts/migration-preflight.mjs
Normal file
358
scripts/migration-preflight.mjs
Normal file
|
|
@ -0,0 +1,358 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
scripts/request-password-reset.mjs
Normal file
56
scripts/request-password-reset.mjs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
197
scripts/verify-backup-restore.mjs
Normal file
197
scripts/verify-backup-restore.mjs
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
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 });
|
||||||
|
}
|
||||||
83
scripts/verify-http-smoke.mjs
Normal file
83
scripts/verify-http-smoke.mjs
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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" }} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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" }} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export default function ArcadeLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
return <div className="arcade-route">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
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,6 +25,8 @@ interface DeckData {
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
orderJson: string;
|
orderJson: string;
|
||||||
cardResultsJson: string | null;
|
cardResultsJson: string | null;
|
||||||
|
sessionId: string;
|
||||||
|
revision: number;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
@ -42,19 +44,24 @@ export default function DeckStudyPage() {
|
||||||
async function handleRestart() {
|
async function handleRestart() {
|
||||||
if (!deck) return;
|
if (!deck) return;
|
||||||
|
|
||||||
// Clear progress in database for both modes
|
const responses = await Promise.all(
|
||||||
await Promise.all([
|
deck.progress.map((progress) =>
|
||||||
fetch("/api/progress", {
|
fetch("/api/progress", {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SEQUENTIAL" })
|
body: JSON.stringify({
|
||||||
}).catch(() => {}),
|
contentType: "DECK",
|
||||||
fetch("/api/progress", {
|
contentId: deck.id,
|
||||||
method: "DELETE",
|
mode: progress.mode,
|
||||||
headers: { "Content-Type": "application/json" },
|
sessionId: progress.sessionId,
|
||||||
body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SHUFFLED" })
|
}),
|
||||||
}).catch(() => {})
|
})
|
||||||
]);
|
)
|
||||||
|
).catch(() => null);
|
||||||
|
if (!responses || responses.some((response) => !response.ok)) {
|
||||||
|
window.alert("The session could not be restarted. Your saved progress was kept.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Clear locally and force remount
|
// Clear locally and force remount
|
||||||
setDeck({ ...deck, progress: [] });
|
setDeck({ ...deck, progress: [] });
|
||||||
|
|
@ -75,7 +82,8 @@ export default function DeckStudyPage() {
|
||||||
}, [deckId, classSlug, router]);
|
}, [deckId, classSlug, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDeck();
|
const timer = window.setTimeout(() => void fetchDeck(), 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
}, [fetchDeck]);
|
}, [fetchDeck]);
|
||||||
|
|
||||||
if (loading || !deck) {
|
if (loading || !deck) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { ImportModal } from "@/components/import/ImportModal";
|
import { ImportModal } from "@/components/import/ImportModal";
|
||||||
|
|
@ -13,7 +13,6 @@ import {
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
DragOverEvent,
|
|
||||||
DragStartEvent,
|
DragStartEvent,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
useDroppable,
|
useDroppable,
|
||||||
|
|
@ -41,6 +40,8 @@ interface DeckItem {
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
orderJson: string;
|
orderJson: string;
|
||||||
cardResultsJson: string | null;
|
cardResultsJson: string | null;
|
||||||
|
sessionId: string;
|
||||||
|
revision: number;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,15 +51,25 @@ interface MaterialGroup {
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cache: Record<string, { decks: DeckItem[]; groups: MaterialGroup[] }> = {};
|
|
||||||
|
|
||||||
function getProgressLabel(deck: DeckItem) {
|
function getProgressLabel(deck: DeckItem) {
|
||||||
if (!deck.progress?.length) return null;
|
if (!deck.progress?.length) return null;
|
||||||
const prog = deck.progress[0];
|
const prog = deck.progress[0];
|
||||||
const order = JSON.parse(prog.orderJson) as string[];
|
let order: string[];
|
||||||
const results = prog.cardResultsJson
|
let results: Record<string, string>;
|
||||||
? (JSON.parse(prog.cardResultsJson) as 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 correctCount = Object.values(results).filter((r) => r === "correct").length;
|
const correctCount = Object.values(results).filter((r) => r === "correct").length;
|
||||||
const total = order.length;
|
const total = order.length;
|
||||||
const current = Math.min(prog.currentIndex + 1, total);
|
const current = Math.min(prog.currentIndex + 1, total);
|
||||||
|
|
@ -70,10 +81,21 @@ interface SortableDeckCardProps {
|
||||||
deck: DeckItem;
|
deck: DeckItem;
|
||||||
onEdit: (deck: DeckItem) => void;
|
onEdit: (deck: DeckItem) => void;
|
||||||
onDelete: (deck: DeckItem) => void;
|
onDelete: (deck: DeckItem) => void;
|
||||||
|
groups: MaterialGroup[];
|
||||||
|
onMove: (deckId: string, groupId: string | null) => void;
|
||||||
|
moveDisabled?: boolean;
|
||||||
classSlug: string;
|
classSlug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCardProps) {
|
function SortableDeckCard({
|
||||||
|
deck,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
groups,
|
||||||
|
onMove,
|
||||||
|
moveDisabled = false,
|
||||||
|
classSlug,
|
||||||
|
}: SortableDeckCardProps) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: deck.id });
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: deck.id });
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
|
|
@ -117,10 +139,24 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCar
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!confirm("Start over from the beginning? This will clear your current progress for this deck.")) return;
|
if (!confirm("Start over from the beginning? This will clear your current progress for this deck.")) return;
|
||||||
await Promise.all([
|
const responses = await Promise.all(
|
||||||
fetch("/api/progress", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SEQUENTIAL" }) }).catch(() => {}),
|
deck.progress.map((progress) =>
|
||||||
fetch("/api/progress", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SHUFFLED" }) }).catch(() => {})
|
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;
|
||||||
|
}
|
||||||
window.location.href = `/${classSlug}/flashcards/${deck.id}`;
|
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"
|
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"
|
||||||
|
|
@ -136,6 +172,20 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCar
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<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">
|
<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">
|
<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" />
|
<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" />
|
||||||
|
|
@ -172,10 +222,12 @@ function DroppableContainer({ id, children }: { id: string; children: React.Reac
|
||||||
export default function FlashcardsPage() {
|
export default function FlashcardsPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const classSlug = params.classSlug as string;
|
const classSlug = params.classSlug as string;
|
||||||
const [decks, setDecks] = useState<DeckItem[]>(cache[classSlug]?.decks || []);
|
const [decks, setDecks] = useState<DeckItem[]>([]);
|
||||||
const [groups, setGroups] = useState<MaterialGroup[]>(cache[classSlug]?.groups || []);
|
const [groups, setGroups] = useState<MaterialGroup[]>([]);
|
||||||
const [loading, setLoading] = useState(!cache[classSlug]);
|
const [loading, setLoading] = useState(true);
|
||||||
const [animate] = useState(!cache[classSlug]);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
const requestGeneration = useRef(0);
|
||||||
const [showImport, setShowImport] = useState(false);
|
const [showImport, setShowImport] = useState(false);
|
||||||
const [classId, setClassId] = useState<string>("");
|
const [classId, setClassId] = useState<string>("");
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
|
@ -185,13 +237,17 @@ export default function FlashcardsPage() {
|
||||||
const [newGroupName, setNewGroupName] = useState("");
|
const [newGroupName, setNewGroupName] = useState("");
|
||||||
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
|
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
|
||||||
const [editGroupName, setEditGroupName] = useState("");
|
const [editGroupName, setEditGroupName] = useState("");
|
||||||
|
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
const saved = localStorage.getItem('flashcards_collapsed_groups');
|
const saved = localStorage.getItem('flashcards_collapsed_groups');
|
||||||
if (saved) {
|
if (saved) {
|
||||||
try { setCollapsedGroups(JSON.parse(saved)); } catch(e) {}
|
try { setCollapsedGroups(JSON.parse(saved)); } catch {}
|
||||||
}
|
}
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function toggleGroup(id: string) {
|
function toggleGroup(id: string) {
|
||||||
|
|
@ -202,43 +258,54 @@ export default function FlashcardsPage() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchAll = useCallback(async (cId: string) => {
|
const fetchAll = useCallback(async (cId: string, generation = requestGeneration.current, signal?: AbortSignal) => {
|
||||||
try {
|
try {
|
||||||
const [deckRes, groupRes] = await Promise.all([
|
const [deckRes, groupRes] = await Promise.all([
|
||||||
fetch(`/api/decks/list?classId=${cId}`),
|
fetch(`/api/decks/list?classId=${cId}`, { signal }),
|
||||||
fetch(`/api/material-groups?classId=${cId}&type=DECK`),
|
fetch(`/api/material-groups?classId=${cId}&type=DECK`, { signal }),
|
||||||
]);
|
]);
|
||||||
const ds = deckRes.ok ? await deckRes.json() : [];
|
if (!deckRes.ok || !groupRes.ok) throw new Error("Unable to load flashcard library");
|
||||||
const gs = groupRes.ok ? await groupRes.json() : [];
|
const ds = await deckRes.json();
|
||||||
|
const gs = await groupRes.json();
|
||||||
|
if (generation !== requestGeneration.current) return;
|
||||||
setDecks(ds);
|
setDecks(ds);
|
||||||
setGroups(gs);
|
setGroups(gs);
|
||||||
cache[classSlug] = { decks: ds, groups: gs };
|
setLoadError(null);
|
||||||
} catch {
|
} catch (error) {
|
||||||
setDecks([]);
|
if (signal?.aborted || generation !== requestGeneration.current) return;
|
||||||
setGroups([]);
|
setLoadError(error instanceof Error ? error.message : "Unable to load flashcard library");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (generation === requestGeneration.current) setLoading(false);
|
||||||
}
|
}
|
||||||
}, [classSlug]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const generation = ++requestGeneration.current;
|
||||||
async function init() {
|
async function init() {
|
||||||
try {
|
try {
|
||||||
const classRes = await fetch("/api/classes");
|
setLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
|
const classRes = await fetch("/api/classes", { signal: controller.signal });
|
||||||
|
if (!classRes.ok) throw new Error("Unable to load class");
|
||||||
const classes = await classRes.json();
|
const classes = await classRes.json();
|
||||||
const cls = classes.find((c: { slug: string }) => c.slug === classSlug);
|
const cls = classes.find((c: { slug: string }) => c.slug === classSlug);
|
||||||
if (!cls) {
|
if (!cls) throw new Error("Class not found");
|
||||||
setLoading(false);
|
if (generation !== requestGeneration.current) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
setClassId(cls.id);
|
setClassId(cls.id);
|
||||||
fetchAll(cls.id);
|
await fetchAll(cls.id, generation, controller.signal);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted || generation !== requestGeneration.current) return;
|
||||||
|
setLoadError(error instanceof Error ? error.message : "Unable to load flashcard library");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
init();
|
const timer = window.setTimeout(() => void init(), 0);
|
||||||
}, [classSlug, fetchAll]);
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [classSlug, fetchAll, reloadKey]);
|
||||||
|
|
||||||
// Group Management
|
// Group Management
|
||||||
async function handleCreateGroup() {
|
async function handleCreateGroup() {
|
||||||
|
|
@ -250,48 +317,71 @@ export default function FlashcardsPage() {
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const g = await res.json();
|
const g = await res.json();
|
||||||
setGroups((prev) => [...prev, g]);
|
setGroups((prev) => [g, ...prev]);
|
||||||
setIsCreatingGroup(false);
|
setIsCreatingGroup(false);
|
||||||
setNewGroupName("");
|
setNewGroupName("");
|
||||||
|
setActionMessage(`Created group ${g.name}.`);
|
||||||
|
} else {
|
||||||
|
setActionMessage("The group could not be created. Please retry.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRenameGroup(id: string) {
|
async function handleRenameGroup(id: string) {
|
||||||
if (!editGroupName.trim()) return;
|
if (!editGroupName.trim()) return;
|
||||||
await fetch(`/api/material-groups/${id}`, {
|
const response = await fetch(`/api/material-groups/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name: editGroupName.trim() }),
|
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)));
|
setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name: editGroupName.trim() } : g)));
|
||||||
setEditingGroupId(null);
|
setEditingGroupId(null);
|
||||||
|
setActionMessage("Group renamed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteGroup(id: string, name: string) {
|
async function handleDeleteGroup(id: string, name: string) {
|
||||||
if (!confirm(`Delete group "${name}"? Decks inside will be moved to Uncategorized.`)) return;
|
if (!confirm(`Delete group "${name}"? Decks inside will be moved to Uncategorized.`)) return;
|
||||||
await fetch(`/api/material-groups/${id}`, { method: "DELETE" });
|
const response = await fetch(`/api/material-groups/${id}`, { method: "DELETE" });
|
||||||
setGroups((prev) => prev.filter((g) => g.id !== id));
|
if (!response.ok) {
|
||||||
setDecks((prev) => prev.map((d) => (d.groupId === id ? { ...d, groupId: null } : d)));
|
setActionMessage("The group could not be deleted. No changes were applied.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetchAll(classId);
|
||||||
|
setActionMessage(`Deleted group ${name}; its decks are now Uncategorized.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deck Management
|
// Deck Management
|
||||||
async function handleDeleteDeck(deck: DeckItem) {
|
async function handleDeleteDeck(deck: DeckItem) {
|
||||||
if (!confirm(`Delete "${deck.name}" and all its cards?`)) return;
|
if (!confirm(`Delete "${deck.name}" and all its cards?`)) return;
|
||||||
await fetch(`/api/decks/${deck.id}`, { method: "DELETE" });
|
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;
|
||||||
|
}
|
||||||
setDecks((prev) => prev.filter((d) => d.id !== deck.id));
|
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) {
|
async function handleRenameDeck(id: string) {
|
||||||
if (!editName.trim()) return;
|
if (!editName.trim()) return;
|
||||||
await fetch(`/api/decks/${id}`, {
|
const response = await fetch(`/api/decks/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name: editName.trim(), description: editDescription.trim() || null }),
|
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) =>
|
setDecks((prev) =>
|
||||||
prev.map((d) => (d.id === id ? { ...d, name: editName.trim(), description: editDescription.trim() || null } : d))
|
prev.map((d) => (d.id === id ? { ...d, name: editName.trim(), description: editDescription.trim() || null } : d))
|
||||||
);
|
);
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
|
setActionMessage("Deck updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drag and drop setup
|
// Drag and drop setup
|
||||||
|
|
@ -301,15 +391,13 @@ export default function FlashcardsPage() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [isReordering, setIsReordering] = useState(false);
|
||||||
|
|
||||||
function handleDragStart(event: DragStartEvent) {
|
function handleDragStart(event: DragStartEvent) {
|
||||||
|
if (isReordering) return;
|
||||||
setActiveId(event.active.id as string);
|
setActiveId(event.active.id as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragOver(event: DragOverEvent) {
|
|
||||||
// optional layout updates here
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDragEnd(event: DragEndEvent) {
|
async function handleDragEnd(event: DragEndEvent) {
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
|
|
@ -334,40 +422,76 @@ export default function FlashcardsPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetGroupId !== undefined) {
|
const oldIndex = decks.findIndex((deck) => deck.id === activeId);
|
||||||
setDecks((items) => {
|
const overIndex = decks.findIndex((deck) => deck.id === overId);
|
||||||
const oldIndex = items.findIndex((d) => d.id === activeId);
|
let nextItems = decks.map((deck) =>
|
||||||
const overIndex = items.findIndex((d) => d.id === overId);
|
deck.id === activeId ? { ...deck, groupId: targetGroupId } : { ...deck }
|
||||||
let newItems = [...items];
|
);
|
||||||
|
|
||||||
newItems[oldIndex].groupId = targetGroupId;
|
|
||||||
|
|
||||||
if (overIndex >= 0 && overIndex !== oldIndex) {
|
if (overIndex >= 0 && overIndex !== oldIndex) {
|
||||||
newItems = arrayMove(newItems, oldIndex, overIndex);
|
nextItems = arrayMove(nextItems, oldIndex, overIndex);
|
||||||
} else {
|
} else {
|
||||||
const oldItem = newItems.splice(oldIndex, 1)[0];
|
const [moved] = nextItems.splice(oldIndex, 1);
|
||||||
newItems.push(oldItem);
|
nextItems.push(moved);
|
||||||
|
}
|
||||||
|
await persistDeckReorder(
|
||||||
|
decks,
|
||||||
|
nextItems,
|
||||||
|
new Set([activeDeck.groupId, targetGroupId])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const affectedGroups = new Set([activeDeck.groupId, targetGroupId]);
|
async function persistDeckReorder(
|
||||||
const updates: ReorderItem[] = [];
|
previousItems: DeckItem[],
|
||||||
|
proposedItems: DeckItem[],
|
||||||
affectedGroups.forEach(gId => {
|
affectedGroups: Set<string | null>
|
||||||
const gItems = newItems.filter(d => d.groupId === gId);
|
) {
|
||||||
gItems.forEach((item, index) => {
|
const counters = new Map<string | null, number>();
|
||||||
item.sortOrder = index;
|
const nextItems = proposedItems.map((item) => {
|
||||||
updates.push({ id: item.id, sortOrder: index, groupId: item.groupId });
|
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 }));
|
||||||
|
|
||||||
fetch("/api/decks/reorder", {
|
setDecks(nextItems);
|
||||||
|
setIsReordering(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/decks/reorder", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ items: updates }),
|
body: JSON.stringify({ items: updates }),
|
||||||
});
|
});
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return newItems;
|
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}.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -381,6 +505,11 @@ export default function FlashcardsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-20">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<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>
|
<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>
|
||||||
|
|
@ -443,15 +572,23 @@ export default function FlashcardsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && decks.length === 0 && groups.length === 0 && (
|
{!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 && (
|
||||||
<div className="text-center py-16">
|
<div className="text-center py-16">
|
||||||
<h3 className="text-lg font-semibold text-text-heading mb-1">No decks yet</h3>
|
<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>
|
<p className="text-text-secondary mb-4">Import your first deck or create a group</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && (
|
{!loading && !loadError && (
|
||||||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{groupedDecks.map(group => (
|
{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">
|
<div key={group.id} className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
|
||||||
|
|
@ -485,7 +622,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>
|
<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 => (
|
group.decks.map(deck => (
|
||||||
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} classSlug={classSlug} />
|
<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} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</DroppableContainer>
|
</DroppableContainer>
|
||||||
|
|
@ -510,7 +647,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>
|
<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 => (
|
uncategorizedDecks.map(deck => (
|
||||||
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} classSlug={classSlug} />
|
<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} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</DroppableContainer>
|
</DroppableContainer>
|
||||||
|
|
@ -522,7 +659,7 @@ export default function FlashcardsPage() {
|
||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
{activeDeck ? (
|
{activeDeck ? (
|
||||||
<div className="opacity-80 scale-105 shadow-xl rotate-2">
|
<div className="opacity-80 scale-105 shadow-xl rotate-2">
|
||||||
<SortableDeckCard deck={activeDeck} onEdit={()=>{}} onDelete={()=>{}} classSlug={classSlug} />
|
<SortableDeckCard deck={activeDeck} onEdit={()=>{}} onDelete={()=>{}} groups={groups} onMove={()=>{}} moveDisabled classSlug={classSlug} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ interface QuizAttempt {
|
||||||
score: number;
|
score: number;
|
||||||
maxScore: number;
|
maxScore: number;
|
||||||
answersJson: string;
|
answersJson: string;
|
||||||
|
reviewSnapshotJson?: string | null;
|
||||||
isPartialRetake: boolean;
|
isPartialRetake: boolean;
|
||||||
completedAt: string;
|
completedAt: string;
|
||||||
}
|
}
|
||||||
|
|
@ -42,6 +43,8 @@ interface QuizData {
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
orderJson: string;
|
orderJson: string;
|
||||||
answersJson: string | null;
|
answersJson: string | null;
|
||||||
|
sessionId: string;
|
||||||
|
revision: number;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,13 +63,23 @@ export default function QuizStudyPage() {
|
||||||
|
|
||||||
async function handleRestart() {
|
async function handleRestart() {
|
||||||
if (!quiz) return;
|
if (!quiz) return;
|
||||||
|
const progress = quiz.progress.find((item) => item.mode === "SEQUENTIAL");
|
||||||
// Clear progress in database
|
if (progress) {
|
||||||
await fetch("/api/progress", {
|
const response = await fetch("/api/progress", {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ contentType: "QUIZ", contentId: quiz.id, mode: "SEQUENTIAL" })
|
body: JSON.stringify({
|
||||||
}).catch(() => {});
|
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 locally and force remount
|
// Clear locally and force remount
|
||||||
setQuiz({ ...quiz, progress: [] });
|
setQuiz({ ...quiz, progress: [] });
|
||||||
|
|
@ -104,7 +117,8 @@ export default function QuizStudyPage() {
|
||||||
}, [quizId, classSlug, router]);
|
}, [quizId, classSlug, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchQuizAndAttempts();
|
const timer = window.setTimeout(() => void fetchQuizAndAttempts(), 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
}, [fetchQuizAndAttempts]);
|
}, [fetchQuizAndAttempts]);
|
||||||
|
|
||||||
const [showTopics, setShowTopics] = useState(false);
|
const [showTopics, setShowTopics] = useState(false);
|
||||||
|
|
@ -221,6 +235,7 @@ export default function QuizStudyPage() {
|
||||||
key={restartKey}
|
key={restartKey}
|
||||||
quiz={quiz}
|
quiz={quiz}
|
||||||
retakeIds={retakeIds}
|
retakeIds={retakeIds}
|
||||||
|
sessionKey={restartKey}
|
||||||
onFinished={() => {
|
onFinished={() => {
|
||||||
router.push(`/${classSlug}/quizzes`);
|
router.push(`/${classSlug}/quizzes`);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { ImportModal } from "@/components/import/ImportModal";
|
import { ImportModal } from "@/components/import/ImportModal";
|
||||||
|
|
@ -13,7 +13,6 @@ import {
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
DragOverEvent,
|
|
||||||
DragStartEvent,
|
DragStartEvent,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
useDroppable,
|
useDroppable,
|
||||||
|
|
@ -41,6 +40,8 @@ interface QuizItem {
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
orderJson: string;
|
orderJson: string;
|
||||||
answersJson: string | null;
|
answersJson: string | null;
|
||||||
|
sessionId: string;
|
||||||
|
revision: number;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,16 +51,25 @@ interface MaterialGroup {
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cache: Record<string, { quizzes: QuizItem[]; groups: MaterialGroup[] }> = {};
|
|
||||||
|
|
||||||
interface SortableQuizCardProps {
|
interface SortableQuizCardProps {
|
||||||
quiz: QuizItem;
|
quiz: QuizItem;
|
||||||
onEdit: (quiz: QuizItem) => void;
|
onEdit: (quiz: QuizItem) => void;
|
||||||
onDelete: (quiz: QuizItem) => void;
|
onDelete: (quiz: QuizItem) => void;
|
||||||
|
groups: MaterialGroup[];
|
||||||
|
onMove: (quizId: string, groupId: string | null) => void;
|
||||||
|
moveDisabled?: boolean;
|
||||||
classSlug: string;
|
classSlug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCardProps) {
|
function SortableQuizCard({
|
||||||
|
quiz,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
groups,
|
||||||
|
onMove,
|
||||||
|
moveDisabled = false,
|
||||||
|
classSlug,
|
||||||
|
}: SortableQuizCardProps) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: quiz.id });
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: quiz.id });
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
|
|
@ -103,11 +113,22 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCar
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!confirm("Start over from the beginning? This will clear your current progress for this quiz.")) return;
|
if (!confirm("Start over from the beginning? This will clear your current progress for this quiz.")) return;
|
||||||
await fetch("/api/progress", {
|
const progress = quiz.progress.find((item) => item.mode === "SEQUENTIAL");
|
||||||
|
if (!progress) return;
|
||||||
|
const response = await fetch("/api/progress", {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ contentType: "QUIZ", contentId: quiz.id, mode: "SEQUENTIAL" }),
|
body: JSON.stringify({
|
||||||
}).catch(() => {});
|
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;
|
||||||
|
}
|
||||||
window.location.href = `/${classSlug}/quizzes/${quiz.id}`;
|
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"
|
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"
|
||||||
|
|
@ -123,6 +144,20 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCar
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<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">
|
<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">
|
<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" />
|
<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" />
|
||||||
|
|
@ -159,10 +194,12 @@ function DroppableContainer({ id, children }: { id: string; children: React.Reac
|
||||||
export default function QuizzesPage() {
|
export default function QuizzesPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const classSlug = params.classSlug as string;
|
const classSlug = params.classSlug as string;
|
||||||
const [quizzes, setQuizzes] = useState<QuizItem[]>(cache[classSlug]?.quizzes || []);
|
const [quizzes, setQuizzes] = useState<QuizItem[]>([]);
|
||||||
const [groups, setGroups] = useState<MaterialGroup[]>(cache[classSlug]?.groups || []);
|
const [groups, setGroups] = useState<MaterialGroup[]>([]);
|
||||||
const [loading, setLoading] = useState(!cache[classSlug]);
|
const [loading, setLoading] = useState(true);
|
||||||
const [animate] = useState(!cache[classSlug]);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
const requestGeneration = useRef(0);
|
||||||
const [showImport, setShowImport] = useState(false);
|
const [showImport, setShowImport] = useState(false);
|
||||||
const [classId, setClassId] = useState<string>("");
|
const [classId, setClassId] = useState<string>("");
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
|
@ -172,13 +209,17 @@ export default function QuizzesPage() {
|
||||||
const [newGroupName, setNewGroupName] = useState("");
|
const [newGroupName, setNewGroupName] = useState("");
|
||||||
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
|
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
|
||||||
const [editGroupName, setEditGroupName] = useState("");
|
const [editGroupName, setEditGroupName] = useState("");
|
||||||
|
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
const saved = localStorage.getItem('quizzes_collapsed_groups');
|
const saved = localStorage.getItem('quizzes_collapsed_groups');
|
||||||
if (saved) {
|
if (saved) {
|
||||||
try { setCollapsedGroups(JSON.parse(saved)); } catch(e) {}
|
try { setCollapsedGroups(JSON.parse(saved)); } catch {}
|
||||||
}
|
}
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function toggleGroup(id: string) {
|
function toggleGroup(id: string) {
|
||||||
|
|
@ -189,43 +230,54 @@ export default function QuizzesPage() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchAll = useCallback(async (cId: string) => {
|
const fetchAll = useCallback(async (cId: string, generation = requestGeneration.current, signal?: AbortSignal) => {
|
||||||
try {
|
try {
|
||||||
const [quizRes, groupRes] = await Promise.all([
|
const [quizRes, groupRes] = await Promise.all([
|
||||||
fetch(`/api/quizzes/list?classId=${cId}`),
|
fetch(`/api/quizzes/list?classId=${cId}`, { signal }),
|
||||||
fetch(`/api/material-groups?classId=${cId}&type=QUIZ`),
|
fetch(`/api/material-groups?classId=${cId}&type=QUIZ`, { signal }),
|
||||||
]);
|
]);
|
||||||
const qs = quizRes.ok ? await quizRes.json() : [];
|
if (!quizRes.ok || !groupRes.ok) throw new Error("Unable to load quiz library");
|
||||||
const gs = groupRes.ok ? await groupRes.json() : [];
|
const qs = await quizRes.json();
|
||||||
|
const gs = await groupRes.json();
|
||||||
|
if (generation !== requestGeneration.current) return;
|
||||||
setQuizzes(qs);
|
setQuizzes(qs);
|
||||||
setGroups(gs);
|
setGroups(gs);
|
||||||
cache[classSlug] = { quizzes: qs, groups: gs };
|
setLoadError(null);
|
||||||
} catch {
|
} catch (error) {
|
||||||
setQuizzes([]);
|
if (signal?.aborted || generation !== requestGeneration.current) return;
|
||||||
setGroups([]);
|
setLoadError(error instanceof Error ? error.message : "Unable to load quiz library");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (generation === requestGeneration.current) setLoading(false);
|
||||||
}
|
}
|
||||||
}, [classSlug]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const generation = ++requestGeneration.current;
|
||||||
async function init() {
|
async function init() {
|
||||||
try {
|
try {
|
||||||
const classRes = await fetch("/api/classes");
|
setLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
|
const classRes = await fetch("/api/classes", { signal: controller.signal });
|
||||||
|
if (!classRes.ok) throw new Error("Unable to load class");
|
||||||
const classes = await classRes.json();
|
const classes = await classRes.json();
|
||||||
const cls = classes.find((c: { slug: string }) => c.slug === classSlug);
|
const cls = classes.find((c: { slug: string }) => c.slug === classSlug);
|
||||||
if (!cls) {
|
if (!cls) throw new Error("Class not found");
|
||||||
setLoading(false);
|
if (generation !== requestGeneration.current) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
setClassId(cls.id);
|
setClassId(cls.id);
|
||||||
fetchAll(cls.id);
|
await fetchAll(cls.id, generation, controller.signal);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted || generation !== requestGeneration.current) return;
|
||||||
|
setLoadError(error instanceof Error ? error.message : "Unable to load quiz library");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
init();
|
const timer = window.setTimeout(() => void init(), 0);
|
||||||
}, [classSlug, fetchAll]);
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [classSlug, fetchAll, reloadKey]);
|
||||||
|
|
||||||
// Group Management
|
// Group Management
|
||||||
async function handleCreateGroup() {
|
async function handleCreateGroup() {
|
||||||
|
|
@ -237,48 +289,70 @@ export default function QuizzesPage() {
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const g = await res.json();
|
const g = await res.json();
|
||||||
setGroups((prev) => [...prev, g]);
|
setGroups((prev) => [g, ...prev]);
|
||||||
setIsCreatingGroup(false);
|
setIsCreatingGroup(false);
|
||||||
setNewGroupName("");
|
setNewGroupName("");
|
||||||
|
setActionMessage(`Created group ${g.name}.`);
|
||||||
|
} else {
|
||||||
|
setActionMessage("The group could not be created. Please retry.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRenameGroup(id: string) {
|
async function handleRenameGroup(id: string) {
|
||||||
if (!editGroupName.trim()) return;
|
if (!editGroupName.trim()) return;
|
||||||
await fetch(`/api/material-groups/${id}`, {
|
const response = await fetch(`/api/material-groups/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name: editGroupName.trim() }),
|
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)));
|
setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name: editGroupName.trim() } : g)));
|
||||||
setEditingGroupId(null);
|
setEditingGroupId(null);
|
||||||
|
setActionMessage("Group renamed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteGroup(id: string, name: string) {
|
async function handleDeleteGroup(id: string, name: string) {
|
||||||
if (!confirm(`Delete group "${name}"? Quizzes inside will be moved to Uncategorized.`)) return;
|
if (!confirm(`Delete group "${name}"? Quizzes inside will be moved to Uncategorized.`)) return;
|
||||||
await fetch(`/api/material-groups/${id}`, { method: "DELETE" });
|
const response = await fetch(`/api/material-groups/${id}`, { method: "DELETE" });
|
||||||
setGroups((prev) => prev.filter((g) => g.id !== id));
|
if (!response.ok) {
|
||||||
setQuizzes((prev) => prev.map((q) => (q.groupId === id ? { ...q, groupId: null } : q)));
|
setActionMessage("The group could not be deleted. No changes were applied.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetchAll(classId);
|
||||||
|
setActionMessage(`Deleted group ${name}; its quizzes are now Uncategorized.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quiz Management
|
// Quiz Management
|
||||||
async function handleDeleteQuiz(quiz: QuizItem) {
|
async function handleDeleteQuiz(quiz: QuizItem) {
|
||||||
if (!confirm(`Delete "${quiz.name}" and all its questions and attempts?`)) return;
|
if (!confirm(`Delete "${quiz.name}" and all its questions and attempts?`)) return;
|
||||||
await fetch(`/api/quizzes/${quiz.id}`, { method: "DELETE" });
|
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;
|
||||||
|
}
|
||||||
setQuizzes((prev) => prev.filter((q) => q.id !== quiz.id));
|
setQuizzes((prev) => prev.filter((q) => q.id !== quiz.id));
|
||||||
|
setActionMessage(`Deleted quiz ${quiz.name}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRenameQuiz(id: string) {
|
async function handleRenameQuiz(id: string) {
|
||||||
if (!editName.trim()) return;
|
if (!editName.trim()) return;
|
||||||
await fetch(`/api/quizzes/${id}`, {
|
const response = await fetch(`/api/quizzes/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name: editName.trim(), description: editDescription.trim() || null }),
|
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) =>
|
setQuizzes((prev) =>
|
||||||
prev.map((q) => (q.id === id ? { ...q, name: editName.trim(), description: editDescription.trim() || null } : q))
|
prev.map((q) => (q.id === id ? { ...q, name: editName.trim(), description: editDescription.trim() || null } : q))
|
||||||
);
|
);
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
|
setActionMessage("Quiz updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drag and drop setup
|
// Drag and drop setup
|
||||||
|
|
@ -288,26 +362,13 @@ export default function QuizzesPage() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [isReordering, setIsReordering] = useState(false);
|
||||||
|
|
||||||
function handleDragStart(event: DragStartEvent) {
|
function handleDragStart(event: DragStartEvent) {
|
||||||
|
if (isReordering) return;
|
||||||
setActiveId(event.active.id as string);
|
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) {
|
async function handleDragEnd(event: DragEndEvent) {
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
|
|
@ -333,43 +394,76 @@ export default function QuizzesPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetGroupId !== undefined) {
|
const oldIndex = quizzes.findIndex((quiz) => quiz.id === activeId);
|
||||||
setQuizzes((items) => {
|
const overIndex = quizzes.findIndex((quiz) => quiz.id === overId);
|
||||||
const oldIndex = items.findIndex((q) => q.id === activeId);
|
let nextItems = quizzes.map((quiz) =>
|
||||||
const overIndex = items.findIndex((q) => q.id === overId);
|
quiz.id === activeId ? { ...quiz, groupId: targetGroupId } : { ...quiz }
|
||||||
let newItems = [...items];
|
);
|
||||||
|
|
||||||
newItems[oldIndex].groupId = targetGroupId;
|
|
||||||
|
|
||||||
if (overIndex >= 0 && overIndex !== oldIndex) {
|
if (overIndex >= 0 && overIndex !== oldIndex) {
|
||||||
newItems = arrayMove(newItems, oldIndex, overIndex);
|
nextItems = arrayMove(nextItems, oldIndex, overIndex);
|
||||||
} else {
|
} else {
|
||||||
// just moved to end of a group
|
const [moved] = nextItems.splice(oldIndex, 1);
|
||||||
const oldItem = newItems.splice(oldIndex, 1)[0];
|
nextItems.push(moved);
|
||||||
newItems.push(oldItem);
|
}
|
||||||
|
await persistQuizReorder(
|
||||||
|
quizzes,
|
||||||
|
nextItems,
|
||||||
|
new Set([activeQuiz.groupId, targetGroupId])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-calculate sortOrder for the affected groups to persist
|
async function persistQuizReorder(
|
||||||
const affectedGroups = new Set([activeQuiz.groupId, targetGroupId]);
|
previousItems: QuizItem[],
|
||||||
const updates: ReorderItem[] = [];
|
proposedItems: QuizItem[],
|
||||||
|
affectedGroups: Set<string | null>
|
||||||
affectedGroups.forEach(gId => {
|
) {
|
||||||
const gItems = newItems.filter(q => q.groupId === gId);
|
const counters = new Map<string | null, number>();
|
||||||
gItems.forEach((item, index) => {
|
const nextItems = proposedItems.map((item) => {
|
||||||
item.sortOrder = index;
|
if (!affectedGroups.has(item.groupId)) return item;
|
||||||
updates.push({ id: item.id, sortOrder: index, groupId: item.groupId });
|
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 }));
|
||||||
|
|
||||||
// Fire API
|
setQuizzes(nextItems);
|
||||||
fetch("/api/quizzes/reorder", {
|
setIsReordering(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/quizzes/reorder", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ items: updates }),
|
body: JSON.stringify({ items: updates }),
|
||||||
});
|
});
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return newItems;
|
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}.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -384,6 +478,11 @@ export default function QuizzesPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-20">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<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>
|
<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>
|
||||||
|
|
@ -447,15 +546,23 @@ export default function QuizzesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && quizzes.length === 0 && groups.length === 0 && (
|
{!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 && (
|
||||||
<div className="text-center py-16">
|
<div className="text-center py-16">
|
||||||
<h3 className="text-lg font-semibold text-text-heading mb-1">No quizzes yet</h3>
|
<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>
|
<p className="text-text-secondary mb-4">Import your first quiz or create a group</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && (
|
{!loading && !loadError && (
|
||||||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{groupedQuizzes.map(group => (
|
{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">
|
<div key={group.id} className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
|
||||||
|
|
@ -476,7 +583,7 @@ export default function QuizzesPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ShareMenu targetType="GROUP" contentId={group.id} classSlug={params.classSlug as string} />
|
<ShareMenu targetType="GROUP" contentId={group.id} classSlug={params.classSlug as string} containsQuizAnswers />
|
||||||
<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={() => { 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>
|
<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>
|
</div>
|
||||||
|
|
@ -489,7 +596,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>
|
<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 => (
|
group.quizzes.map(quiz => (
|
||||||
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} classSlug={classSlug} />
|
<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} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</DroppableContainer>
|
</DroppableContainer>
|
||||||
|
|
@ -514,7 +621,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>
|
<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 => (
|
uncategorizedQuizzes.map(quiz => (
|
||||||
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} classSlug={classSlug} />
|
<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} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</DroppableContainer>
|
</DroppableContainer>
|
||||||
|
|
@ -526,7 +633,7 @@ export default function QuizzesPage() {
|
||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
{activeQuiz ? (
|
{activeQuiz ? (
|
||||||
<div className="opacity-80 scale-105 shadow-xl rotate-2">
|
<div className="opacity-80 scale-105 shadow-xl rotate-2">
|
||||||
<SortableQuizCard quiz={activeQuiz} onEdit={()=>{}} onDelete={()=>{}} classSlug={classSlug} />
|
<SortableQuizCard quiz={activeQuiz} onEdit={()=>{}} onDelete={()=>{}} groups={groups} onMove={()=>{}} moveDisabled classSlug={classSlug} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ const accents = ["#4f46e5", "#f97360", "#25845f", "#c27718", "#8b5cf6", "#0284c7
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [classes, setClasses] = useState<ClassItem[]>([]);
|
const [classes, setClasses] = useState<ClassItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
@ -27,13 +28,25 @@ export default function HomePage() {
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
const [savingEdit, setSavingEdit] = useState(false);
|
||||||
|
|
||||||
useEffect(() => { fetchClasses(); }, []);
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
void fetchClasses(controller.signal);
|
||||||
|
return () => controller.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function fetchClasses() {
|
async function fetchClasses(signal?: AbortSignal) {
|
||||||
|
setLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/classes");
|
const res = await fetch("/api/classes", { signal });
|
||||||
|
if (!res.ok) throw new Error("The server could not load your classes.");
|
||||||
setClasses(await res.json());
|
setClasses(await res.json());
|
||||||
} finally { setLoading(false); }
|
} catch (error) {
|
||||||
|
if (signal?.aborted) return;
|
||||||
|
setLoadError(error instanceof Error ? error.message : "Unable to load classes.");
|
||||||
|
} finally {
|
||||||
|
if (!signal?.aborted) setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate(e: React.FormEvent) {
|
async function handleCreate(e: React.FormEvent) {
|
||||||
|
|
@ -42,14 +55,21 @@ export default function HomePage() {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/classes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newName.trim() }) });
|
const res = await fetch("/api/classes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newName.trim() }) });
|
||||||
if (res.ok) { setNewName(""); setShowCreate(false); fetchClasses(); }
|
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.");
|
||||||
} finally { setCreating(false); }
|
} finally { setCreating(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string, name: string) {
|
async function handleDelete(id: string, name: string) {
|
||||||
if (!confirm(`Delete "${name}" and all its decks and quizzes?`)) return;
|
if (!confirm(`Delete "${name}" and all its decks and quizzes?`)) return;
|
||||||
await fetch(`/api/classes/${id}`, { method: "DELETE" });
|
const response = await fetch(`/api/classes/${id}`, { method: "DELETE" }).catch(() => null);
|
||||||
fetchClasses();
|
if (!response?.ok) {
|
||||||
|
setLoadError("The class could not be deleted. Retry when the server is available.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void fetchClasses();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveEdit(id: string) {
|
async function handleSaveEdit(id: string) {
|
||||||
|
|
@ -57,7 +77,10 @@ export default function HomePage() {
|
||||||
setSavingEdit(true);
|
setSavingEdit(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/classes/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: editName.trim() }) });
|
const res = await fetch(`/api/classes/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: editName.trim() }) });
|
||||||
if (res.ok) { setEditingId(null); fetchClasses(); }
|
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.");
|
||||||
} finally { setSavingEdit(false); }
|
} finally { setSavingEdit(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,7 +116,15 @@ export default function HomePage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && classes.length === 0 && (
|
{!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 && (
|
||||||
<div className="paper-grid rounded-[2rem] border border-dashed border-border bg-bg-surface px-6 py-16 text-center">
|
<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>
|
<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>
|
<h2 className="editorial-title text-2xl text-text-heading">Your desk is ready</h2>
|
||||||
|
|
@ -102,7 +133,7 @@ export default function HomePage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && classes.length > 0 && (
|
{!loading && !loadError && classes.length > 0 && (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{classes.map((cls, index) => (
|
{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)]">
|
<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)]">
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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}`));
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
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,8 +4,7 @@ import { checkRateLimit } from "@/lib/rateLimiter";
|
||||||
import { login } from "@/services/authService";
|
import { login } from "@/services/authService";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
if (!checkRateLimit("login:global").allowed) {
|
||||||
if (!checkRateLimit(`login:${ip}`).allowed) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Too many requests. Try again shortly." },
|
{ error: "Too many requests. Try again shortly." },
|
||||||
{ status: 429 }
|
{ status: 429 }
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,20 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { completePasswordResetSchema } from "@/lib/validation/authSchemas";
|
import { completePasswordResetSchema } from "@/lib/validation/authSchemas";
|
||||||
|
import { checkRateLimit } from "@/lib/rateLimiter";
|
||||||
import { completePasswordReset } from "@/services/authService";
|
import { completePasswordReset } from "@/services/authService";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
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(
|
const parsed = completePasswordResetSchema.safeParse(
|
||||||
await request.json().catch(() => null)
|
await request.json().catch(() => null)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { checkRateLimit } from "@/lib/rateLimiter";
|
import { checkRateLimit } from "@/lib/rateLimiter";
|
||||||
import { requestPasswordReset } from "@/services/authService";
|
import { requestPasswordReset } from "@/services/authService";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST() {
|
||||||
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
if (process.env.NODE_ENV === "production") {
|
||||||
const rateLimit = checkRateLimit(`password-reset-request:${ip}`, {
|
return NextResponse.json(
|
||||||
windowMs: 60_000,
|
{ error: "Start password recovery from the local server console." },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimit = checkRateLimit("password-reset-request:global", {
|
||||||
|
windowMs: 15 * 60_000,
|
||||||
maxRequests: 1,
|
maxRequests: 1,
|
||||||
});
|
});
|
||||||
if (!rateLimit.allowed) {
|
if (!rateLimit.allowed) {
|
||||||
|
|
@ -15,11 +21,18 @@ export async function POST(request: NextRequest) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await requestPasswordReset())) {
|
const result = await requestPasswordReset();
|
||||||
|
if (result.status === "missing-password") {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Password recovery is unavailable before initial setup." },
|
{ error: "Password recovery is unavailable before initial setup." },
|
||||||
{ status: 409 }
|
{ status: 409 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return NextResponse.json({ success: true });
|
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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,7 @@ import { resetTokenSchema } from "@/lib/validation/authSchemas";
|
||||||
import { verifyPasswordResetToken } from "@/services/authService";
|
import { verifyPasswordResetToken } from "@/services/authService";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
if (!checkRateLimit("password-reset-verify:global").allowed) {
|
||||||
if (!checkRateLimit(`password-reset-verify:${ip}`).allowed) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Too many attempts. Try again shortly." },
|
{ error: "Too many attempts. Try again shortly." },
|
||||||
{ status: 429 }
|
{ status: 429 }
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { getSetupStatus } from "@/services/authService";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const setting = await prisma.setting.findUnique({
|
return NextResponse.json(await getSetupStatus());
|
||||||
where: { key: "admin_password_hash" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ setupRequired: !setting });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to check setup status:", error);
|
console.error("Failed to check setup status:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,25 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import * as cardService from "@/services/cardService";
|
import * as cardService from "@/services/cardService";
|
||||||
|
import { cardUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas";
|
||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
ctx: RouteContext<"/api/cards/[id]">
|
ctx: RouteContext<"/api/cards/[id]">
|
||||||
) {
|
) {
|
||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
const body = await request.json().catch(() => null);
|
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 });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const updated = await cardService.updateCard(id, {
|
return NextResponse.json(await cardService.updateCard(id, parsed.data));
|
||||||
front: body?.front?.trim(),
|
} catch (error) {
|
||||||
back: body?.back?.trim(),
|
if (isPrismaError(error, "P2025")) {
|
||||||
});
|
|
||||||
return NextResponse.json(updated);
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
|
|
@ -24,11 +27,14 @@ export async function DELETE(
|
||||||
ctx: RouteContext<"/api/cards/[id]">
|
ctx: RouteContext<"/api/cards/[id]">
|
||||||
) {
|
) {
|
||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await cardService.deleteCard(id);
|
await cardService.deleteCard(id);
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (isPrismaError(error, "P2025")) {
|
||||||
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import * as classService from "@/services/classService";
|
import * as classService from "@/services/classService";
|
||||||
|
import { classUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas";
|
||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
ctx: RouteContext<"/api/classes/[id]">
|
ctx: RouteContext<"/api/classes/[id]">
|
||||||
) {
|
) {
|
||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
const body = await request.json().catch(() => null);
|
const parsed = classUpdateSchema.safeParse(await request.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
if (!body || (!body.name && body.name !== "")) {
|
return NextResponse.json({ error: "A valid class name is required" }, { status: 400 });
|
||||||
return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await classService.updateClass(id, {
|
return NextResponse.json(await classService.updateClass(id, parsed.data));
|
||||||
name: body.name?.trim(),
|
} catch (error) {
|
||||||
});
|
if (isPrismaError(error, "P2025")) {
|
||||||
return NextResponse.json(updated);
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: "Class not found" }, { status: 404 });
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
|
|
@ -27,11 +27,14 @@ export async function DELETE(
|
||||||
ctx: RouteContext<"/api/classes/[id]">
|
ctx: RouteContext<"/api/classes/[id]">
|
||||||
) {
|
) {
|
||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await classService.deleteClass(id);
|
await classService.deleteClass(id);
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (isPrismaError(error, "P2025")) {
|
||||||
return NextResponse.json({ error: "Class not found" }, { status: 404 });
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,23 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import * as classService from "@/services/classService";
|
import * as classService from "@/services/classService";
|
||||||
|
import { classCreateSchema, isPrismaError } from "@/lib/validation/contentSchemas";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const classes = await classService.listClasses();
|
return NextResponse.json(await classService.listClasses());
|
||||||
return NextResponse.json(classes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const body = await request.json().catch(() => null);
|
const parsed = classCreateSchema.safeParse(await request.json().catch(() => null));
|
||||||
if (!body?.name || typeof body.name !== "string" || !body.name.trim()) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "Class name is required and must be 160 characters or fewer" }, { status: 400 });
|
||||||
{ error: "Class name is required" },
|
}
|
||||||
{ 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 newClass = await classService.createClass(body.name.trim());
|
|
||||||
return NextResponse.json(newClass, { status: 201 });
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,26 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import * as cardService from "@/services/cardService";
|
import * as cardService from "@/services/cardService";
|
||||||
|
import { cardContentSchema, isPrismaError } from "@/lib/validation/contentSchemas";
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
ctx: RouteContext<"/api/decks/[id]/cards">
|
ctx: RouteContext<"/api/decks/[id]/cards">
|
||||||
) {
|
) {
|
||||||
const { id: deckId } = await ctx.params;
|
const { id: deckId } = await ctx.params;
|
||||||
const body = await request.json().catch(() => null);
|
const parsed = cardContentSchema.safeParse(await request.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
if (
|
|
||||||
!body?.front ||
|
|
||||||
!body?.back ||
|
|
||||||
typeof body.front !== "string" ||
|
|
||||||
typeof body.back !== "string"
|
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "front and back are required" },
|
{ error: "Card front and back are required and must be within the content limit", details: parsed.error.issues },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
const card = await cardService.createCard(deckId, {
|
return NextResponse.json(await cardService.createCard(deckId, parsed.data), { status: 201 });
|
||||||
front: body.front.trim(),
|
} catch (error) {
|
||||||
back: body.back.trim(),
|
if (isPrismaError(error, "P2003")) {
|
||||||
});
|
return NextResponse.json({ error: "Deck not found" }, { status: 404 });
|
||||||
|
}
|
||||||
return NextResponse.json(card, { status: 201 });
|
console.error("Failed to create card", error);
|
||||||
|
return NextResponse.json({ error: "Failed to create card" }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import * as deckService from "@/services/deckService";
|
import * as deckService from "@/services/deckService";
|
||||||
|
import { contentUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
|
|
@ -20,17 +21,22 @@ export async function PATCH(
|
||||||
ctx: RouteContext<"/api/decks/[id]">
|
ctx: RouteContext<"/api/decks/[id]">
|
||||||
) {
|
) {
|
||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
const body = await request.json().catch(() => null);
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await deckService.updateDeck(id, {
|
const updated = await deckService.updateDeck(id, parsed.data);
|
||||||
name: body?.name?.trim(),
|
|
||||||
description: body?.description,
|
|
||||||
});
|
|
||||||
return NextResponse.json(updated);
|
return NextResponse.json(updated);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (isPrismaError(error, "P2025")) {
|
||||||
return NextResponse.json({ error: "Deck not found" }, { status: 404 });
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
|
|
@ -42,7 +48,11 @@ export async function DELETE(
|
||||||
try {
|
try {
|
||||||
await deckService.deleteDeck(id);
|
await deckService.deleteDeck(id);
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (isPrismaError(error, "P2025")) {
|
||||||
return NextResponse.json({ error: "Deck not found" }, { status: 404 });
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,29 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { reorderRequestSchema } from "@/lib/validation/reorderSchemas";
|
import { reorderRequestSchema } from "@/lib/validation/reorderSchemas";
|
||||||
|
import {
|
||||||
|
ReorderConflictError,
|
||||||
|
ReorderValidationError,
|
||||||
|
reorderContent,
|
||||||
|
} from "@/services/reorderService";
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
const parsed = reorderRequestSchema.safeParse(
|
||||||
const parsed = reorderRequestSchema.safeParse(await request.json());
|
await request.json().catch(() => null)
|
||||||
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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Invalid reorder input" }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await reorderContent("DECK", parsed.data);
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} 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 });
|
return NextResponse.json({ error: "Failed to reorder decks" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,40 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import * as deckService from "@/services/deckService";
|
import * as deckService from "@/services/deckService";
|
||||||
import { flashcardImportSchema } from "@/lib/validation/importSchemas";
|
import { deckImportRequestSchema } from "@/lib/validation/importSchemas";
|
||||||
|
import { readLimitedJson, RequestTooLargeError } from "@/lib/limitedJson";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const body = await request.json().catch(() => null);
|
let body: unknown;
|
||||||
|
try {
|
||||||
if (!body) {
|
body = await readLimitedJson(request);
|
||||||
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
} catch (error) {
|
||||||
|
if (error instanceof RequestTooLargeError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 413 });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { classId, data, name, groupId } = body;
|
const parsed = deckImportRequestSchema.safeParse(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) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Validation failed", details: parsed.error.issues },
|
{ error: "Invalid deck import", details: parsed.error.issues },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const deck = await deckService.createDeckFromImport(
|
const deck = await deckService.createDeckFromImport(
|
||||||
classId,
|
parsed.data.classId,
|
||||||
parsed.data,
|
parsed.data.data,
|
||||||
name?.trim() || undefined,
|
parsed.data.name,
|
||||||
groupId || null
|
parsed.data.groupId ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json(deck, { status: 201 });
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
src/app/api/health/route.ts
Normal file
12
src/app/api/health/route.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
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,42 +1,44 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { materialGroupUpdateSchema } from "@/lib/validation/materialGroupSchemas";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
import {
|
||||||
|
deleteMaterialGroup,
|
||||||
|
renameMaterialGroup,
|
||||||
|
} from "@/services/materialGroupService";
|
||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const body = await request.json();
|
const parsed = materialGroupUpdateSchema.safeParse(
|
||||||
const { name, sortOrder } = body;
|
await request.json().catch(() => null)
|
||||||
|
);
|
||||||
const updateData: Prisma.MaterialGroupUpdateInput = {};
|
if (!parsed.success) {
|
||||||
if (name !== undefined) updateData.name = name;
|
return NextResponse.json({ error: "Invalid group update" }, { status: 400 });
|
||||||
if (sortOrder !== undefined) updateData.sortOrder = sortOrder;
|
}
|
||||||
|
try {
|
||||||
const group = await prisma.materialGroup.update({
|
return NextResponse.json(await renameMaterialGroup(id, parsed.data.name));
|
||||||
where: { id },
|
|
||||||
data: updateData,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json(group);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: "Failed to update material group" }, { status: 500 });
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
await prisma.materialGroup.delete({
|
if (!(await deleteMaterialGroup(id))) {
|
||||||
where: { id },
|
return NextResponse.json({ error: "Group not found" }, { status: 404 });
|
||||||
});
|
}
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json({ error: "Failed to delete material group" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,38 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
materialGroupCreateSchema,
|
||||||
|
materialGroupQuerySchema,
|
||||||
|
} from "@/lib/validation/materialGroupSchemas";
|
||||||
|
import {
|
||||||
|
createMaterialGroup,
|
||||||
|
listMaterialGroups,
|
||||||
|
} from "@/services/materialGroupService";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const parsed = materialGroupQuerySchema.safeParse(
|
||||||
const classId = searchParams.get("classId");
|
Object.fromEntries(request.nextUrl.searchParams)
|
||||||
const type = searchParams.get("type");
|
);
|
||||||
|
if (!parsed.success) {
|
||||||
if (!classId) {
|
return NextResponse.json({ error: "Invalid group query" }, { status: 400 });
|
||||||
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
const parsed = materialGroupCreateSchema.safeParse(
|
||||||
const body = await request.json();
|
await request.json().catch(() => null)
|
||||||
const { classId, name, type } = body;
|
);
|
||||||
|
if (!parsed.success) {
|
||||||
if (!classId || !name || (type !== "DECK" && type !== "QUIZ")) {
|
return NextResponse.json(
|
||||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
{ error: parsed.error.issues[0]?.message ?? "Invalid group" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const group = await createMaterialGroup(parsed.data);
|
||||||
|
if (!group) {
|
||||||
|
return NextResponse.json({ error: "Class not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
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 });
|
return NextResponse.json(group, { status: 201 });
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json({ error: "Failed to create material group" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,64 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import * as progressService from "@/services/progressService";
|
import {
|
||||||
|
progressDeleteSchema,
|
||||||
|
progressPatchSchema,
|
||||||
|
progressQuerySchema,
|
||||||
|
} from "@/lib/validation/progressSchemas";
|
||||||
|
import {
|
||||||
|
ProgressConflictError,
|
||||||
|
ProgressNotFoundError,
|
||||||
|
ProgressValidationError,
|
||||||
|
clearProgress,
|
||||||
|
getProgress,
|
||||||
|
saveProgress,
|
||||||
|
} from "@/services/progressService";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const parsed = progressQuerySchema.safeParse(
|
||||||
const contentType = searchParams.get("contentType") as "DECK" | "QUIZ";
|
Object.fromEntries(request.nextUrl.searchParams)
|
||||||
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 }
|
|
||||||
);
|
);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Invalid progress query" }, { 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) {
|
export async function PATCH(request: NextRequest) {
|
||||||
const body = await request.json().catch(() => null);
|
const parsed = progressPatchSchema.safeParse(
|
||||||
|
await request.json().catch(() => null)
|
||||||
if (!body?.contentType || !body?.contentId || !body?.mode) {
|
);
|
||||||
|
if (!parsed.success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "contentType, contentId, and mode are required" },
|
{ error: parsed.error.issues[0]?.message ?? "Invalid progress" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
const progress = await progressService.upsertProgress({
|
return NextResponse.json(await saveProgress(parsed.data));
|
||||||
contentType: body.contentType,
|
} catch (error) {
|
||||||
contentId: body.contentId,
|
if (error instanceof ProgressNotFoundError) {
|
||||||
mode: body.mode,
|
return NextResponse.json({ error: error.message }, { status: 404 });
|
||||||
currentIndex: body.currentIndex ?? 0,
|
}
|
||||||
orderJson: body.orderJson,
|
if (error instanceof ProgressConflictError) {
|
||||||
answersJson: body.answersJson,
|
return NextResponse.json({ error: error.message }, { status: 409 });
|
||||||
cardResultsJson: body.cardResultsJson,
|
}
|
||||||
});
|
if (error instanceof ProgressValidationError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||||
return NextResponse.json(progress);
|
}
|
||||||
|
console.error("Failed to save study progress", error);
|
||||||
|
return NextResponse.json({ error: "Failed to save progress" }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
const body = await request.json().catch(() => null);
|
const parsed = progressDeleteSchema.safeParse(
|
||||||
|
await request.json().catch(() => null)
|
||||||
if (!body?.contentType || !body?.contentId || !body?.mode) {
|
);
|
||||||
|
if (!parsed.success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "contentType, contentId, and mode are required" },
|
{ error: parsed.error.issues[0]?.message ?? "Invalid progress deletion" },
|
||||||
{ status: 400 }
|
{ 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,65 +1,40 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getQuizSetWithQuestions, createQuizAttempt, listQuizAttempts } from "@/services/quizService";
|
import {
|
||||||
import { scoreQuiz } from "@/lib/scoring";
|
QuizAttemptValidationError,
|
||||||
|
QuizNotFoundError,
|
||||||
|
listQuizAttempts,
|
||||||
|
submitQuizAttempt,
|
||||||
|
} from "@/services/quizService";
|
||||||
|
import { quizAttemptSchema } from "@/lib/validation/attemptSchemas";
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
ctx: RouteContext<"/api/quizzes/[id]/attempt">
|
ctx: RouteContext<"/api/quizzes/[id]/attempt">
|
||||||
) {
|
) {
|
||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
const body = await request.json().catch(() => null);
|
const parsed = quizAttemptSchema.safeParse(
|
||||||
|
await request.json().catch(() => null)
|
||||||
if (!body || !body.answersJson) {
|
);
|
||||||
|
if (!parsed.success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "answersJson is required" },
|
{ error: parsed.error.issues[0]?.message ?? "Invalid attempt" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const quizSet = await getQuizSetWithQuestions(id);
|
|
||||||
if (!quizSet) {
|
|
||||||
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse answers
|
|
||||||
let answers: Record<string, string[]>;
|
|
||||||
try {
|
try {
|
||||||
answers = JSON.parse(body.answersJson);
|
return NextResponse.json(await submitQuizAttempt(id, parsed.data), {
|
||||||
} catch {
|
status: 201,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
return NextResponse.json(attempt, { status: 201 });
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import * as quizService from "@/services/quizService";
|
import * as quizService from "@/services/quizService";
|
||||||
|
import { contentUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
|
|
@ -20,17 +21,22 @@ export async function PATCH(
|
||||||
ctx: RouteContext<"/api/quizzes/[id]">
|
ctx: RouteContext<"/api/quizzes/[id]">
|
||||||
) {
|
) {
|
||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
const body = await request.json().catch(() => null);
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await quizService.updateQuizSet(id, {
|
const updated = await quizService.updateQuizSet(id, parsed.data);
|
||||||
name: body?.name?.trim(),
|
|
||||||
description: body?.description,
|
|
||||||
});
|
|
||||||
return NextResponse.json(updated);
|
return NextResponse.json(updated);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (isPrismaError(error, "P2025")) {
|
||||||
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
|
|
@ -42,7 +48,11 @@ export async function DELETE(
|
||||||
try {
|
try {
|
||||||
await quizService.deleteQuizSet(id);
|
await quizService.deleteQuizSet(id);
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (isPrismaError(error, "P2025")) {
|
||||||
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,29 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { reorderRequestSchema } from "@/lib/validation/reorderSchemas";
|
import { reorderRequestSchema } from "@/lib/validation/reorderSchemas";
|
||||||
|
import {
|
||||||
|
ReorderConflictError,
|
||||||
|
ReorderValidationError,
|
||||||
|
reorderContent,
|
||||||
|
} from "@/services/reorderService";
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
const parsed = reorderRequestSchema.safeParse(
|
||||||
const parsed = reorderRequestSchema.safeParse(await request.json());
|
await request.json().catch(() => null)
|
||||||
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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Invalid reorder input" }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await reorderContent("QUIZ", parsed.data);
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} 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 });
|
return NextResponse.json({ error: "Failed to reorder quizzes" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,43 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import * as quizService from "@/services/quizService";
|
import * as quizService from "@/services/quizService";
|
||||||
import { quizImportSchema } from "@/lib/validation/importSchemas";
|
import { quizImportRequestSchema } from "@/lib/validation/importSchemas";
|
||||||
|
import { readLimitedJson, RequestTooLargeError } from "@/lib/limitedJson";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const body = await request.json().catch(() => null);
|
let body: unknown;
|
||||||
|
try {
|
||||||
if (!body) {
|
body = await readLimitedJson(request);
|
||||||
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
} catch (error) {
|
||||||
|
if (error instanceof RequestTooLargeError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 413 });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { classId, data, name, groupId } = body;
|
const parsed = quizImportRequestSchema.safeParse(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) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Validation failed", details: parsed.error.issues },
|
{
|
||||||
|
error: "Invalid quiz import. SATA questions need at least two correct options.",
|
||||||
|
details: parsed.error.issues,
|
||||||
|
},
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const quizSet = await quizService.createQuizSetFromImport(
|
try {
|
||||||
classId,
|
const quiz = await quizService.createQuizSetFromImport(
|
||||||
parsed.data,
|
parsed.data.classId,
|
||||||
name?.trim() || undefined,
|
parsed.data.data,
|
||||||
groupId || null
|
parsed.data.name,
|
||||||
|
parsed.data.groupId ?? null
|
||||||
);
|
);
|
||||||
|
return NextResponse.json(quiz, { status: 201 });
|
||||||
return NextResponse.json(quizSet, { 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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import * as settingsService from "@/services/settingsService";
|
||||||
|
|
||||||
function getInstructionType(request: NextRequest): settingsService.LlmInstructionType | null {
|
function getInstructionType(request: NextRequest): settingsService.LlmInstructionType | null {
|
||||||
const type = new URL(request.url).searchParams.get("type") ?? "flashcards";
|
const type = new URL(request.url).searchParams.get("type") ?? "flashcards";
|
||||||
return type === "flashcards" || type === "quizzes" || type === "connections" || type === "crossword" ? type : null;
|
return type === "flashcards" || type === "quizzes" ? type : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,21 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { toggleShareLink, getShareLinkForContent, isContentSharedViaGroup } from "@/services/shareService";
|
import { shareTargetSchema } from "@/lib/validation/shareSchemas";
|
||||||
|
import {
|
||||||
|
ShareTargetNotFoundError,
|
||||||
|
getShareLinkForContent,
|
||||||
|
isContentSharedViaGroup,
|
||||||
|
toggleShareLink,
|
||||||
|
} from "@/services/shareService";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const parsed = shareTargetSchema.safeParse(
|
||||||
const targetType = searchParams.get("targetType") as "DECK" | "QUIZ" | "GROUP";
|
Object.fromEntries(request.nextUrl.searchParams)
|
||||||
const contentId = searchParams.get("contentId");
|
|
||||||
|
|
||||||
if (!targetType || !contentId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "targetType and contentId are required" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Invalid share target" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
const { targetType, contentId } = parsed.data;
|
||||||
const link = await getShareLinkForContent(targetType, contentId);
|
const link = await getShareLinkForContent(targetType, contentId);
|
||||||
|
|
||||||
if (targetType === "DECK" || targetType === "QUIZ") {
|
if (targetType === "DECK" || targetType === "QUIZ") {
|
||||||
const groupShare = await isContentSharedViaGroup(targetType, contentId);
|
const groupShare = await isContentSharedViaGroup(targetType, contentId);
|
||||||
if (groupShare) {
|
if (groupShare) {
|
||||||
|
|
@ -25,20 +26,27 @@ 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) {
|
export async function POST(request: NextRequest) {
|
||||||
const body = await request.json().catch(() => null);
|
const parsed = shareTargetSchema.safeParse(
|
||||||
|
await request.json().catch(() => null)
|
||||||
if (!body?.targetType || !body?.contentId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "targetType and contentId are required" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Invalid share target" }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const link = await toggleShareLink(
|
||||||
|
parsed.data.targetType,
|
||||||
|
parsed.data.contentId
|
||||||
|
);
|
||||||
|
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,537 +231,6 @@
|
||||||
color: transparent;
|
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 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 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); }
|
.markdown-content h3 { font-size: 1.125rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,15 @@ import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
||||||
|
|
||||||
type LoginStage = "login" | "token" | "password" | "success";
|
type LoginStage = "login" | "token" | "password" | "success";
|
||||||
|
|
||||||
async function postJson(path: string, body?: object) {
|
async function postJson<T extends object = Record<string, never>>(path: string, body?: object): Promise<T> {
|
||||||
const response = await fetch(path, {
|
const response = await fetch(path, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body ?? {}),
|
body: JSON.stringify(body ?? {}),
|
||||||
});
|
});
|
||||||
const data: { error?: string } = await response.json();
|
const data = await response.json() as T & { error?: string };
|
||||||
if (!response.ok) throw new Error(data.error || "Request failed");
|
if (!response.ok) throw new Error(data.error || "Request failed");
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
|
@ -23,12 +24,26 @@ export default function LoginPage() {
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [setupRequired, setSetupRequired] = useState<boolean | null>(null);
|
const [setupRequired, setSetupRequired] = useState<boolean | null>(null);
|
||||||
|
const [setupAllowed, setSetupAllowed] = useState(false);
|
||||||
|
const [statusError, setStatusError] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/auth/setup-status")
|
const controller = new AbortController();
|
||||||
.then((response) => response.json())
|
fetch("/api/auth/setup-status", { signal: controller.signal })
|
||||||
.then((data) => setSetupRequired(data.setupRequired))
|
.then((response) => {
|
||||||
.catch(() => setSetupRequired(false));
|
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();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function run(action: () => Promise<void>) {
|
async function run(action: () => Promise<void>) {
|
||||||
|
|
@ -53,8 +68,8 @@ export default function LoginPage() {
|
||||||
|
|
||||||
function beginReset() {
|
function beginReset() {
|
||||||
void run(async () => {
|
void run(async () => {
|
||||||
await postJson("/api/auth/password-reset/request");
|
const response = await postJson<{ token: string }>("/api/auth/password-reset/request");
|
||||||
setToken("");
|
setToken(response.token);
|
||||||
setStage("token");
|
setStage("token");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -139,6 +154,12 @@ 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">
|
<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" && (
|
{stage === "login" && (
|
||||||
<form onSubmit={handleLogin} className="space-y-4">
|
<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
|
<PasswordField
|
||||||
id="password"
|
id="password"
|
||||||
label="Password"
|
label="Password"
|
||||||
|
|
@ -148,7 +169,7 @@ export default function LoginPage() {
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<ErrorMessage message={error} />
|
<ErrorMessage message={error} />
|
||||||
<PrimaryButton loading={loading} disabled={password.length < 8}>
|
<PrimaryButton loading={loading} disabled={password.length < 8 || (setupRequired && !setupAllowed)}>
|
||||||
{setupRequired ? "Save Password & Login" : "Sign In"}
|
{setupRequired ? "Save Password & Login" : "Sign In"}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
{!setupRequired && (
|
{!setupRequired && (
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import type { SharedGroupData } from "@/types/study";
|
import type { SharedGroupData } from "@/types/study";
|
||||||
|
|
||||||
export function SharedGroupViewer({ data }: { data: SharedGroupData }) {
|
export function SharedGroupViewer({ data, token }: { data: SharedGroupData; token: string }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|
@ -16,11 +16,12 @@ export function SharedGroupViewer({ data }: { data: SharedGroupData }) {
|
||||||
const [progressMap, setProgressMap] = useState<Record<string, { currentIndex: number, total: number }>>({});
|
const [progressMap, setProgressMap] = useState<Record<string, { currentIndex: number, total: number }>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
const timer = window.setTimeout(() => {
|
||||||
|
|
||||||
const newProgress: Record<string, { currentIndex: number, total: number }> = {};
|
const newProgress: Record<string, { currentIndex: number, total: number }> = {};
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
const key = data.type === "DECK" ? `flashcard_progress_${item.id}` : `quiz_progress_${item.id}`;
|
const key = data.type === "DECK"
|
||||||
|
? `flashcard_progress_${token}_${item.id}`
|
||||||
|
: `quiz_progress_${token}_${item.id}`;
|
||||||
const saved = localStorage.getItem(key);
|
const saved = localStorage.getItem(key);
|
||||||
if (saved) {
|
if (saved) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -30,15 +31,19 @@ export function SharedGroupViewer({ data }: { data: SharedGroupData }) {
|
||||||
currentIndex: parsed.currentIndex || 0,
|
currentIndex: parsed.currentIndex || 0,
|
||||||
total,
|
total,
|
||||||
};
|
};
|
||||||
} catch(e) {}
|
} catch {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setProgressMap(newProgress);
|
setProgressMap(newProgress);
|
||||||
}, [items, data.type]);
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [items, data.type, token]);
|
||||||
|
|
||||||
const handleRestart = (e: React.MouseEvent, itemId: string) => {
|
const handleRestart = (e: React.MouseEvent, itemId: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const key = data.type === "DECK" ? `flashcard_progress_${itemId}` : `quiz_progress_${itemId}`;
|
const key = data.type === "DECK"
|
||||||
|
? `flashcard_progress_${token}_${itemId}`
|
||||||
|
: `quiz_progress_${token}_${itemId}`;
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
setProgressMap(prev => {
|
setProgressMap(prev => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
|
|
|
||||||
|
|
@ -12,24 +12,29 @@ interface SharedViewerProps {
|
||||||
type: "flashcards" | "quizzes";
|
type: "flashcards" | "quizzes";
|
||||||
data: SharedStudyItem;
|
data: SharedStudyItem;
|
||||||
groupMode?: boolean;
|
groupMode?: boolean;
|
||||||
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SharedViewer({ type, data, groupMode = false }: SharedViewerProps) {
|
export function SharedViewer({ type, data, groupMode = false, token }: SharedViewerProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [restartKey, setRestartKey] = useState(0);
|
const [restartKey, setRestartKey] = useState(0);
|
||||||
const [view, setView] = useState<"study" | "list">("study");
|
const [view, setView] = useState<"study" | "list">("study");
|
||||||
const [hasSavedSession, setHasSavedSession] = useState(false);
|
const [hasSavedSession, setHasSavedSession] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if there's a saved session for this item
|
const timer = window.setTimeout(() => {
|
||||||
const key = type === "flashcards" ? `flashcard_progress_${data.id}` : `quiz_progress_${data.id}`;
|
const key = type === "flashcards"
|
||||||
if (localStorage.getItem(key)) {
|
? `flashcard_progress_${token}_${data.id}`
|
||||||
setHasSavedSession(true);
|
: `quiz_progress_${token}_${data.id}`;
|
||||||
}
|
setHasSavedSession(Boolean(localStorage.getItem(key)));
|
||||||
}, [type, data.id, restartKey]);
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [type, data.id, restartKey, token]);
|
||||||
|
|
||||||
function handleRestart() {
|
function handleRestart() {
|
||||||
const key = type === "flashcards" ? `flashcard_progress_${data.id}` : `quiz_progress_${data.id}`;
|
const key = type === "flashcards"
|
||||||
|
? `flashcard_progress_${token}_${data.id}`
|
||||||
|
: `quiz_progress_${token}_${data.id}`;
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
setHasSavedSession(false);
|
setHasSavedSession(false);
|
||||||
setRestartKey(k => k + 1);
|
setRestartKey(k => k + 1);
|
||||||
|
|
@ -141,6 +146,7 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp
|
||||||
cards={data.cards ?? []}
|
cards={data.cards ?? []}
|
||||||
deckId={data.id}
|
deckId={data.id}
|
||||||
isShared={true}
|
isShared={true}
|
||||||
|
storageNamespace={token}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CardList cards={data.cards ?? []} />
|
<CardList cards={data.cards ?? []} />
|
||||||
|
|
@ -156,6 +162,7 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp
|
||||||
}}
|
}}
|
||||||
retakeIds={null}
|
retakeIds={null}
|
||||||
isShared={true}
|
isShared={true}
|
||||||
|
storageNamespace={token}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export async function generateMetadata(
|
||||||
}
|
}
|
||||||
|
|
||||||
const link = await getShareLinkMeta(token);
|
const link = await getShareLinkMeta(token);
|
||||||
return buildShareMetadata(link, { classSlug, itemId });
|
return buildShareMetadata(link, { classSlug, itemId, pathType: type });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function SharedPage(props: SharedPageProps & { searchParams: Promise<{ itemId?: string }> }) {
|
export default async function SharedPage(props: SharedPageProps & { searchParams: Promise<{ itemId?: string }> }) {
|
||||||
|
|
@ -71,18 +71,29 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
||||||
let targetType: SharedContentType = type === "flashcards" ? "flashcards" : "quizzes";
|
let targetType: SharedContentType = type === "flashcards" ? "flashcards" : "quizzes";
|
||||||
|
|
||||||
if (type === "groups" && link.group) {
|
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) {
|
if (itemId) {
|
||||||
// Find the specific item inside the group
|
// Find the specific item inside the group
|
||||||
if (link.group.type === "DECK") {
|
if (link.group.type === "DECK") {
|
||||||
targetData = link.group.decks.find(d => d.id === itemId) ?? null;
|
targetData = validDecks.find(d => d.id === itemId) ?? null;
|
||||||
targetType = "flashcards";
|
targetType = "flashcards";
|
||||||
} else {
|
} else {
|
||||||
targetData = link.group.quizSets.find(q => q.id === itemId) ?? null;
|
targetData = validQuizSets.find(q => q.id === itemId) ?? null;
|
||||||
targetType = "quizzes";
|
targetType = "quizzes";
|
||||||
}
|
}
|
||||||
if (!targetData) notFound();
|
if (!targetData) notFound();
|
||||||
} else {
|
} else {
|
||||||
targetData = link.group;
|
targetData = {
|
||||||
|
...link.group,
|
||||||
|
decks: validDecks,
|
||||||
|
quizSets: validQuizSets,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
targetData = type === "flashcards" ? link.deck : link.quizSet;
|
targetData = type === "flashcards" ? link.deck : link.quizSet;
|
||||||
|
|
@ -126,12 +137,13 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
||||||
<main className="flex-1 overflow-y-auto">
|
<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">
|
<div className="mx-auto max-w-5xl px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
|
||||||
{type === "groups" && !itemId ? (
|
{type === "groups" && !itemId ? (
|
||||||
<SharedGroupViewer data={targetData as SharedGroupData} />
|
<SharedGroupViewer data={targetData as SharedGroupData} token={token} />
|
||||||
) : (
|
) : (
|
||||||
<SharedViewer
|
<SharedViewer
|
||||||
type={targetType}
|
type={targetType}
|
||||||
data={targetData as SharedStudyItem}
|
data={targetData as SharedStudyItem}
|
||||||
groupMode={type === "groups"}
|
groupMode={type === "groups"}
|
||||||
|
token={token}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ interface DailyActivity {
|
||||||
date: string;
|
date: string;
|
||||||
flashcards: number;
|
flashcards: number;
|
||||||
questions: number;
|
questions: number;
|
||||||
arcade: number;
|
|
||||||
total: number;
|
total: number;
|
||||||
level: 0 | 1 | 2 | 3 | 4;
|
level: 0 | 1 | 2 | 3 | 4;
|
||||||
}
|
}
|
||||||
|
|
@ -154,7 +153,7 @@ function DayTooltip({ day }: { day: DailyActivity }) {
|
||||||
return (
|
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 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="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 · {day.arcade} arcade groups</div>
|
<div className="mt-0.5 text-text-secondary dark:text-white/60">{day.flashcards} flashcards · {day.questions} questions</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -185,5 +184,5 @@ function getMonthLabel(week: DailyActivity[], previousWeek?: DailyActivity[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDayAriaLabel(day: DailyActivity) {
|
function getDayAriaLabel(day: DailyActivity) {
|
||||||
return `${day.date}: ${day.flashcards} flashcards, ${day.questions} questions, and ${day.arcade} arcade groups, ${day.total} total activities`;
|
return `${day.date}: ${day.flashcards} flashcards, ${day.questions} questions, ${day.total} total activities`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,237 +0,0 @@
|
||||||
"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>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,269 +0,0 @@
|
||||||
"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>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
"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>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { ConnectionsGame } from "@/components/arcade/ConnectionsGame";
|
|
||||||
import { CrosswordGame } from "@/components/arcade/CrosswordGame";
|
|
||||||
|
|
||||||
export const ARCADE_RENDERERS = {
|
|
||||||
connections: ConnectionsGame,
|
|
||||||
crossword: CrosswordGame,
|
|
||||||
} as const;
|
|
||||||
|
|
@ -41,6 +41,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
|
||||||
body: JSON.stringify({ front: editFront, back: editBack }),
|
body: JSON.stringify({ front: editFront, back: editBack }),
|
||||||
});
|
});
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
|
window.dispatchEvent(new Event("study-decks-changed"));
|
||||||
onCardsChanged();
|
onCardsChanged();
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
|
|
@ -50,6 +51,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
|
||||||
async function deleteCard(id: string) {
|
async function deleteCard(id: string) {
|
||||||
if (!confirm("Delete this card?")) return;
|
if (!confirm("Delete this card?")) return;
|
||||||
await fetch(`/api/cards/${id}`, { method: "DELETE" });
|
await fetch(`/api/cards/${id}`, { method: "DELETE" });
|
||||||
|
window.dispatchEvent(new Event("study-decks-changed"));
|
||||||
onCardsChanged();
|
onCardsChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,6 +67,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
|
||||||
setNewFront("");
|
setNewFront("");
|
||||||
setNewBack("");
|
setNewBack("");
|
||||||
setShowAdd(false);
|
setShowAdd(false);
|
||||||
|
window.dispatchEvent(new Event("study-decks-changed"));
|
||||||
onCardsChanged();
|
onCardsChanged();
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
|
|
@ -89,7 +92,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card list */}
|
{/* Card list */}
|
||||||
{cards.map((card, index) => (
|
{cards.map((card) => (
|
||||||
<div
|
<div
|
||||||
key={card.id}
|
key={card.id}
|
||||||
className="overflow-hidden rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)]"
|
className="overflow-hidden rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)]"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { recordStudyActivity } from "@/lib/activityClient";
|
import { recordStudyActivity } from "@/lib/activityClient";
|
||||||
|
import { normalizeProgress } from "@/lib/progressNormalization";
|
||||||
import {
|
import {
|
||||||
STREAK_FLAME_THRESHOLD,
|
STREAK_FLAME_THRESHOLD,
|
||||||
StreakBurst,
|
StreakBurst,
|
||||||
|
|
@ -25,47 +26,78 @@ interface ProgressData {
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
orderJson: string;
|
orderJson: string;
|
||||||
cardResultsJson: string | null;
|
cardResultsJson: string | null;
|
||||||
|
sessionId: string;
|
||||||
|
revision: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FlashcardViewerProps {
|
interface FlashcardViewerProps {
|
||||||
cards: Card[];
|
cards: Card[];
|
||||||
deckId: string;
|
deckId: string;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
|
storageNamespace?: string;
|
||||||
initialProgress?: ProgressData | null;
|
initialProgress?: ProgressData | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CardResult = "correct" | "missed";
|
type CardResult = "correct" | "missed";
|
||||||
|
|
||||||
export function FlashcardViewer({ cards, deckId, isShared = false, initialProgress }: FlashcardViewerProps) {
|
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));
|
||||||
const [isLoaded, setIsLoaded] = useState(!isShared);
|
const [isLoaded, setIsLoaded] = useState(!isShared);
|
||||||
|
|
||||||
const [order, setOrder] = useState<string[]>(
|
const [order, setOrder] = useState<string[]>(initialState.order);
|
||||||
initialProgress ? JSON.parse(initialProgress.orderJson) : cards.map((c) => c.id)
|
const [currentIndex, setCurrentIndex] = useState(initialState.currentIndex);
|
||||||
);
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(
|
|
||||||
initialProgress ? initialProgress.currentIndex : 0
|
|
||||||
);
|
|
||||||
const [isFlipped, setIsFlipped] = useState(false);
|
const [isFlipped, setIsFlipped] = useState(false);
|
||||||
const [hasFlippedOnce, setHasFlippedOnce] = useState(false);
|
const [hasFlippedOnce, setHasFlippedOnce] = useState(false);
|
||||||
const [results, setResults] = useState<Record<string, CardResult>>(
|
const [results, setResults] = useState<Record<string, CardResult>>(initialState.data);
|
||||||
initialProgress && initialProgress.cardResultsJson
|
|
||||||
? JSON.parse(initialProgress.cardResultsJson)
|
|
||||||
: {}
|
|
||||||
);
|
|
||||||
const [isShuffled, setIsShuffled] = useState<boolean>(
|
const [isShuffled, setIsShuffled] = useState<boolean>(
|
||||||
initialProgress ? initialProgress.mode === "SHUFFLED" : false
|
initialProgress ? initialProgress.mode === "SHUFFLED" : false
|
||||||
);
|
);
|
||||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
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
|
// If progress is provided and index is already at or past the end, it means completed
|
||||||
const [completed, setCompleted] = useState(
|
const [completed, setCompleted] = useState(initialState.completed);
|
||||||
initialProgress
|
|
||||||
? initialProgress.currentIndex >= JSON.parse(initialProgress.orderJson).length
|
|
||||||
: false
|
|
||||||
);
|
|
||||||
const [swipeClass, setSwipeClass] = useState("");
|
const [swipeClass, setSwipeClass] = useState("");
|
||||||
const cardRef = useRef<HTMLDivElement>(null);
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
const gradingRef = useRef(false);
|
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
|
// Touch/drag state
|
||||||
const dragRef = useRef({ startX: 0, currentX: 0, isDragging: false });
|
const dragRef = useRef({ startX: 0, currentX: 0, isDragging: false });
|
||||||
|
|
@ -73,7 +105,6 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
const currentCard = order.length > 0 ? cards.find((c) => c.id === order[currentIndex]) : null;
|
const currentCard = order.length > 0 ? cards.find((c) => c.id === order[currentIndex]) : null;
|
||||||
const correctCount = Object.values(results).filter((r) => r === "correct").length;
|
const correctCount = Object.values(results).filter((r) => r === "correct").length;
|
||||||
const missedCount = Object.values(results).filter((r) => r === "missed").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 run of consecutive correct cards, derived by walking back from the
|
||||||
// current position. Deriving it (rather than storing it) keeps resumed
|
// current position. Deriving it (rather than storing it) keeps resumed
|
||||||
|
|
@ -127,29 +158,104 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
|
|
||||||
// Load from localStorage if shared
|
// Load from localStorage if shared
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isShared) {
|
if (!isShared) return;
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(`flashcard_progress_${deckId}`);
|
const saved = localStorage.getItem(sharedStorageKey);
|
||||||
if (saved) {
|
if (saved) {
|
||||||
const parsed = JSON.parse(saved);
|
const parsed: unknown = JSON.parse(saved);
|
||||||
setOrder(parsed.order || cards.map(c => c.id));
|
const record =
|
||||||
setCurrentIndex(parsed.currentIndex || 0);
|
typeof parsed === "object" && parsed !== null
|
||||||
setResults(parsed.results || {});
|
? (parsed as Record<string, unknown>)
|
||||||
setIsShuffled(parsed.mode === "SHUFFLED");
|
: {};
|
||||||
|
const restored = normalizeProgress({
|
||||||
if (parsed.currentIndex >= (parsed.order?.length || cards.length)) {
|
orderJson: record.order,
|
||||||
setCompleted(true);
|
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."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setToastMessage("Session restored");
|
setToastMessage("Session restored");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Fallback to defaults
|
setRestoreWarning(
|
||||||
|
"Saved progress could not be read. A fresh session was started safely."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setIsLoaded(true);
|
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;
|
||||||
}
|
}
|
||||||
}, [isShared, deckId, cards]);
|
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
|
||||||
function toggleShuffle() {
|
function toggleShuffle() {
|
||||||
|
if (isTransitioning) return;
|
||||||
const newShuffled = !isShuffled;
|
const newShuffled = !isShuffled;
|
||||||
setIsShuffled(newShuffled);
|
setIsShuffled(newShuffled);
|
||||||
|
|
||||||
|
|
@ -175,6 +281,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
(grade: CardResult) => {
|
(grade: CardResult) => {
|
||||||
if (!currentCard || !hasFlippedOnce || gradingRef.current) return;
|
if (!currentCard || !hasFlippedOnce || gradingRef.current) return;
|
||||||
gradingRef.current = true;
|
gradingRef.current = true;
|
||||||
|
setIsTransitioning(true);
|
||||||
|
|
||||||
if (!isShared) {
|
if (!isShared) {
|
||||||
recordStudyActivity("FLASHCARD").catch(() => {});
|
recordStudyActivity("FLASHCARD").catch(() => {});
|
||||||
|
|
@ -187,15 +294,17 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
// Animate
|
// Animate
|
||||||
setSwipeClass(grade === "correct" ? "animate-swipe-right" : "animate-swipe-left");
|
setSwipeClass(grade === "correct" ? "animate-swipe-right" : "animate-swipe-left");
|
||||||
|
|
||||||
setTimeout(() => {
|
transitionTimerRef.current = setTimeout(() => {
|
||||||
gradingRef.current = false;
|
gradingRef.current = false;
|
||||||
|
setIsTransitioning(false);
|
||||||
setSwipeClass("");
|
setSwipeClass("");
|
||||||
setIsFlipped(false);
|
setIsFlipped(false);
|
||||||
setHasFlippedOnce(false);
|
setHasFlippedOnce(false);
|
||||||
|
|
||||||
if (currentIndex + 1 >= order.length) {
|
if (currentIndex + 1 >= order.length) {
|
||||||
setCompleted(true);
|
setCompleted(true);
|
||||||
saveProgress(order, currentIndex, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
|
setCurrentIndex(order.length);
|
||||||
|
saveProgress(order, order.length, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
|
||||||
} else {
|
} else {
|
||||||
const nextIndex = currentIndex + 1;
|
const nextIndex = currentIndex + 1;
|
||||||
setCurrentIndex(nextIndex);
|
setCurrentIndex(nextIndex);
|
||||||
|
|
@ -203,13 +312,13 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
}
|
}
|
||||||
}, 350);
|
}, 350);
|
||||||
},
|
},
|
||||||
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled, isShared]
|
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled, isShared, saveProgress]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
if (completed) return;
|
if (completed || isTransitioning) return;
|
||||||
|
|
||||||
if (e.key === " " || e.key === "Enter" || e.key === "ArrowUp" || e.key === "ArrowDown") {
|
if (e.key === " " || e.key === "Enter" || e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -227,7 +336,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [completed, isFlipped, gradeCard]);
|
}, [completed, isFlipped, gradeCard, hasFlippedOnce, isTransitioning]);
|
||||||
|
|
||||||
// Touch handlers for swipe
|
// Touch handlers for swipe
|
||||||
function handleTouchStart(e: React.TouchEvent) {
|
function handleTouchStart(e: React.TouchEvent) {
|
||||||
|
|
@ -262,39 +371,9 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// Restart handlers
|
||||||
function restartFullSet() {
|
function restartFullSet() {
|
||||||
|
if (isTransitioning) return;
|
||||||
gradingRef.current = false;
|
gradingRef.current = false;
|
||||||
let newOrder: string[];
|
let newOrder: string[];
|
||||||
if (isShuffled) {
|
if (isShuffled) {
|
||||||
|
|
@ -312,6 +391,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
}
|
}
|
||||||
|
|
||||||
function redoMissed() {
|
function redoMissed() {
|
||||||
|
if (isTransitioning) return;
|
||||||
const missedIds = Object.entries(results)
|
const missedIds = Object.entries(results)
|
||||||
.filter(([, r]) => r === "missed")
|
.filter(([, r]) => r === "missed")
|
||||||
.map(([id]) => id);
|
.map(([id]) => id);
|
||||||
|
|
@ -337,6 +417,11 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
if (completed) {
|
if (completed) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16">
|
<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="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">
|
<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">
|
<svg className="w-7 h-7 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
@ -393,6 +478,11 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
// Study view
|
// Study view
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center">
|
<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 */}
|
{/* 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="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">
|
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 text-sm">
|
||||||
|
|
@ -421,6 +511,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
className={`perspective-1000 cursor-pointer select-none relative ${swipeClass}`}
|
className={`perspective-1000 cursor-pointer select-none relative ${swipeClass}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (isTransitioning) return;
|
||||||
setToastMessage(null);
|
setToastMessage(null);
|
||||||
setIsFlipped(!isFlipped);
|
setIsFlipped(!isFlipped);
|
||||||
if (!isFlipped) setHasFlippedOnce(true);
|
if (!isFlipped) setHasFlippedOnce(true);
|
||||||
|
|
@ -506,7 +597,8 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
<div className="flex items-center gap-2 md:gap-4 animate-fade-in pointer-events-auto">
|
<div className="flex items-center gap-2 md:gap-4 animate-fade-in pointer-events-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => gradeCard("missed")}
|
onClick={() => gradeCard("missed")}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
|
@ -515,7 +607,8 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => gradeCard("correct")}
|
onClick={() => gradeCard("correct")}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
|
@ -531,6 +624,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
{currentIndex > 0 ? (
|
{currentIndex > 0 ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (isTransitioning) return;
|
||||||
const prevIndex = currentIndex - 1;
|
const prevIndex = currentIndex - 1;
|
||||||
setCurrentIndex(prevIndex);
|
setCurrentIndex(prevIndex);
|
||||||
setIsFlipped(false);
|
setIsFlipped(false);
|
||||||
|
|
@ -542,6 +636,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
setResults(newResults);
|
setResults(newResults);
|
||||||
saveProgress(order, prevIndex, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
|
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"
|
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"
|
title="Previous Card"
|
||||||
>
|
>
|
||||||
|
|
@ -553,6 +648,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={toggleShuffle}
|
onClick={toggleShuffle}
|
||||||
|
disabled={isTransitioning}
|
||||||
className={`p-2 rounded-full transition-all duration-200 cursor-pointer bg-bg-base/50 ${
|
className={`p-2 rounded-full transition-all duration-200 cursor-pointer bg-bg-base/50 ${
|
||||||
isShuffled
|
isShuffled
|
||||||
? "text-primary hover:bg-primary/10"
|
? "text-primary hover:bg-primary/10"
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,11 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validCards = cards.filter((c) => c.front.trim() && c.back.trim());
|
const incompleteRows = cards.flatMap((card, index) =>
|
||||||
if (validCards.length === 0) {
|
card.front.trim() && card.back.trim() ? [] : [index + 1]
|
||||||
setError("Please add at least one complete card.");
|
);
|
||||||
|
if (incompleteRows.length > 0) {
|
||||||
|
setError(`Complete or remove card row${incompleteRows.length === 1 ? "" : "s"}: ${incompleteRows.join(", ")}.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,7 +63,7 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) {
|
||||||
type: "flashcards",
|
type: "flashcards",
|
||||||
deckName: deckName,
|
deckName: deckName,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
cards: validCards.map((c) => ({
|
cards: cards.map((c) => ({
|
||||||
front: c.front,
|
front: c.front,
|
||||||
back: c.back,
|
back: c.back,
|
||||||
})),
|
})),
|
||||||
|
|
@ -70,9 +72,11 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error("Failed to create deck");
|
const response = await res.json().catch(() => null) as { error?: string } | null;
|
||||||
|
throw new Error(response?.error ?? "Failed to create deck");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.dispatchEvent(new Event("study-decks-changed"));
|
||||||
onCreated();
|
onCreated();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to create deck");
|
setError(err instanceof Error ? err.message : "Failed to create deck");
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,40 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
|
||||||
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" | "connections" | "crossword" }) {
|
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" }) {
|
||||||
const [instructions, setInstructions] = useState("");
|
const [instructions, setInstructions] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveResult, setSaveResult] = useState<"success" | "error" | null>(null);
|
const [saveResult, setSaveResult] = useState<"success" | "error" | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [packCount, setPackCount] = useState(1);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
const batchOverride = importType === "connections" && packCount > 1
|
const loadInstructions = useCallback(async (signal?: AbortSignal) => {
|
||||||
? `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`
|
setLoading(true);
|
||||||
: "";
|
setLoadError(null);
|
||||||
const displayedInstructions = `${batchOverride}${instructions}`;
|
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]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/settings/llm-instructions?type=${importType}`)
|
const controller = new AbortController();
|
||||||
.then((res) => res.json())
|
const timer = window.setTimeout(() => void loadInstructions(controller.signal), 0);
|
||||||
.then((data) => setInstructions(data.value))
|
return () => {
|
||||||
.finally(() => setLoading(false));
|
window.clearTimeout(timer);
|
||||||
}, [importType]);
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [loadInstructions]);
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
@ -50,46 +64,51 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ value: "__RESET__" }),
|
body: JSON.stringify({ value: "__RESET__" }),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to reset instructions");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
if (typeof data.value !== "string") throw new Error("Invalid reset response");
|
||||||
setInstructions(data.value);
|
setInstructions(data.value);
|
||||||
|
setSaveResult("success");
|
||||||
|
} catch {
|
||||||
|
setSaveResult("error");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCopy() {
|
async function handleCopy() {
|
||||||
await navigator.clipboard.writeText(displayedInstructions);
|
try {
|
||||||
|
await navigator.clipboard.writeText(instructions);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
setLoadError("Clipboard access failed. Select and copy the instructions manually.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="animate-subtle-pulse text-text-muted">Loading instructions...</div>;
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<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">
|
<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.
|
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>
|
</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
|
<textarea
|
||||||
value={displayedInstructions}
|
value={instructions}
|
||||||
onChange={(event) => setInstructions(batchOverride && event.target.value.startsWith(batchOverride) ? event.target.value.slice(batchOverride.length) : event.target.value)}
|
onChange={(event) => setInstructions(event.target.value)}
|
||||||
rows={14}
|
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"
|
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,6 +134,9 @@ export function ImportTab({ classId, importType, onImported }: ImportTabProps) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (importType === "flashcards") {
|
||||||
|
window.dispatchEvent(new Event("study-decks-changed"));
|
||||||
|
}
|
||||||
onImported();
|
onImported();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Network error during import");
|
setError("Network error during import");
|
||||||
|
|
@ -170,12 +173,12 @@ export function ImportTab({ classId, importType, onImported }: ImportTabProps) {
|
||||||
onChange={(e) => setSelectedGroupId(e.target.value)}
|
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"
|
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) => (
|
{materialGroups.map((group) => (
|
||||||
<option key={group.id} value={group.id}>
|
<option key={group.id} value={group.id}>
|
||||||
{group.name}
|
{group.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
<option value="">Uncategorized</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import remarkGfm from "remark-gfm";
|
||||||
import { CategoryBreakdown } from "./CategoryBreakdown";
|
import { CategoryBreakdown } from "./CategoryBreakdown";
|
||||||
import { scoreQuestion } from "@/lib/scoring";
|
import { scoreQuestion } from "@/lib/scoring";
|
||||||
import type { QuizAttempt, QuizQuestion, QuizSummary } from "@/types/study";
|
import type { QuizAttempt, QuizQuestion, QuizSummary } from "@/types/study";
|
||||||
|
import { parseQuizReviewSnapshot } from "@/lib/quizSnapshots";
|
||||||
|
|
||||||
interface QuizResultsProps {
|
interface QuizResultsProps {
|
||||||
quiz: QuizSummary;
|
quiz: QuizSummary;
|
||||||
|
|
@ -20,17 +21,28 @@ interface ReviewItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsProps) {
|
export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsProps) {
|
||||||
|
const snapshot = parseQuizReviewSnapshot(attempt.reviewSnapshotJson);
|
||||||
let answers: Record<string, string[]> = {};
|
let answers: Record<string, string[]> = {};
|
||||||
try {
|
try {
|
||||||
answers = JSON.parse(attempt.answersJson);
|
answers = JSON.parse(attempt.answersJson);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
if (snapshot) {
|
||||||
|
answers = Object.fromEntries(
|
||||||
|
snapshot.questions.map((question) => [question.id, question.selections])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Determine non-full-credit questions for review list
|
// Determine non-full-credit questions for review list
|
||||||
const reviewItems: ReviewItem[] = [];
|
const reviewItems: ReviewItem[] = [];
|
||||||
const missedIds: string[] = [];
|
const missedIds: string[] = [];
|
||||||
|
|
||||||
const answeredIds = Object.keys(answers);
|
const answeredIds = Object.keys(answers);
|
||||||
const questionsToReview = quiz.questions.filter((q) => answeredIds.includes(q.id));
|
const questionsToReview = snapshot?.questions ??
|
||||||
|
quiz.questions.filter((q) => answeredIds.includes(q.id));
|
||||||
|
const snapshotPoints = new Map(
|
||||||
|
snapshot?.questions.map((question) => [question.id, question.points]) ?? []
|
||||||
|
);
|
||||||
|
|
||||||
questionsToReview.forEach((q) => {
|
questionsToReview.forEach((q) => {
|
||||||
const formattedQ = {
|
const formattedQ = {
|
||||||
|
|
@ -38,7 +50,8 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
||||||
type: q.type as "MULTIPLE_CHOICE" | "SATA",
|
type: q.type as "MULTIPLE_CHOICE" | "SATA",
|
||||||
options: q.options,
|
options: q.options,
|
||||||
};
|
};
|
||||||
const score = scoreQuestion(formattedQ, answers[q.id] || []);
|
const score = snapshotPoints.get(q.id) ??
|
||||||
|
scoreQuestion(formattedQ, answers[q.id] || []);
|
||||||
|
|
||||||
// If not full credit (1.0), add to review and missed arrays
|
// If not full credit (1.0), add to review and missed arrays
|
||||||
if (score < 1) {
|
if (score < 1) {
|
||||||
|
|
@ -50,6 +63,9 @@ 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;
|
const percentage = attempt.maxScore > 0 ? (attempt.score / attempt.maxScore) * 100 : 0;
|
||||||
|
|
||||||
|
|
@ -88,12 +104,12 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{missedIds.length > 0 && (
|
{retakeableMissedIds.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onRetake(missedIds)}
|
onClick={() => onRetake(retakeableMissedIds)}
|
||||||
className="px-6 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
|
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 ({missedIds.length})
|
Retake Missed ({retakeableMissedIds.length})
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -112,7 +128,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
||||||
{/* Category Breakdown */}
|
{/* Category Breakdown */}
|
||||||
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-6 md:p-8">
|
<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>
|
<h3 className="text-lg font-bold text-text-heading mb-6">Category Breakdown</h3>
|
||||||
<CategoryBreakdown quiz={quiz} answeredIds={answeredIds} answers={answers} />
|
<CategoryBreakdown quiz={reviewQuiz} answeredIds={answeredIds} answers={answers} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Review List */}
|
{/* Review List */}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { scoreQuestion } from "@/lib/scoring";
|
||||||
import { QuizResults } from "./QuizResults";
|
import { QuizResults } from "./QuizResults";
|
||||||
import { recordStudyActivity } from "@/lib/activityClient";
|
import { recordStudyActivity } from "@/lib/activityClient";
|
||||||
import type { QuizAttempt } from "@/types/study";
|
import type { QuizAttempt } from "@/types/study";
|
||||||
|
import { normalizeProgress } from "@/lib/progressNormalization";
|
||||||
|
|
||||||
interface Option {
|
interface Option {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -33,16 +34,39 @@ interface QuizViewerProps {
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
orderJson: string;
|
orderJson: string;
|
||||||
answersJson: string | null;
|
answersJson: string | null;
|
||||||
|
sessionId: string;
|
||||||
|
revision: number;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
retakeIds: string[] | null;
|
retakeIds: string[] | null;
|
||||||
|
sessionKey?: string | number;
|
||||||
|
storageNamespace?: string;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
onFinished?: () => void;
|
onFinished?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: QuizViewerProps) {
|
export function QuizViewer({
|
||||||
|
quiz,
|
||||||
|
retakeIds,
|
||||||
|
sessionKey = "default",
|
||||||
|
storageNamespace,
|
||||||
|
isShared = false,
|
||||||
|
onFinished,
|
||||||
|
}: QuizViewerProps) {
|
||||||
const submittingQuestionRef = useRef<string | null>(null);
|
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 [order, setOrder] = useState<string[]>([]);
|
||||||
|
const [attemptScope, setAttemptScope] = useState<string[]>([]);
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const [answers, setAnswers] = useState<Record<string, string[]>>({});
|
const [answers, setAnswers] = useState<Record<string, string[]>>({});
|
||||||
const [submittedAnswers, setSubmittedAnswers] = useState<string[]>([]);
|
const [submittedAnswers, setSubmittedAnswers] = useState<string[]>([]);
|
||||||
|
|
@ -51,6 +75,11 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [resultsData, setResultsData] = useState<QuizAttempt | null>(null);
|
const [resultsData, setResultsData] = useState<QuizAttempt | null>(null);
|
||||||
const [toastMessage, setToastMessage] = useState<string | 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
|
// Auto-hide toast
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -62,16 +91,18 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
|
|
||||||
// Initialize session
|
// Initialize session
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
// Generate shuffled options once per session mount
|
// Generate shuffled options once per session mount
|
||||||
const newShuffledOpts: Record<string, Option[]> = {};
|
const newShuffledOpts: Record<string, Option[]> = {};
|
||||||
for (const q of quiz.questions) {
|
for (const q of initialQuiz.questions) {
|
||||||
newShuffledOpts[q.id] = [...q.options].sort(() => Math.random() - 0.5);
|
newShuffledOpts[q.id] = [...q.options].sort(() => Math.random() - 0.5);
|
||||||
}
|
}
|
||||||
setShuffledOptions(newShuffledOpts);
|
setShuffledOptions(newShuffledOpts);
|
||||||
|
|
||||||
// If retaking specific questions
|
// If retaking specific questions
|
||||||
if (retakeIds && retakeIds.length > 0) {
|
if (initialRetakeIds && initialRetakeIds.length > 0) {
|
||||||
setOrder(retakeIds);
|
setOrder(initialRetakeIds);
|
||||||
|
setAttemptScope(initialRetakeIds);
|
||||||
setCurrentIndex(0);
|
setCurrentIndex(0);
|
||||||
setAnswers({});
|
setAnswers({});
|
||||||
setSubmittedAnswers([]);
|
setSubmittedAnswers([]);
|
||||||
|
|
@ -82,62 +113,120 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
// Shared links: load from localStorage
|
// Shared links: load from localStorage
|
||||||
if (isShared) {
|
if (isShared) {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(`quiz_progress_${quiz.id}`);
|
const saved = localStorage.getItem(sharedStorageKey);
|
||||||
if (saved) {
|
if (saved) {
|
||||||
const parsed = JSON.parse(saved);
|
const parsed: unknown = JSON.parse(saved);
|
||||||
setOrder(parsed.order || quiz.questions.map(q => q.id));
|
const record =
|
||||||
setCurrentIndex(parsed.currentIndex || 0);
|
typeof parsed === "object" && parsed !== null
|
||||||
setAnswers(parsed.answers || {});
|
? (parsed as Record<string, unknown>)
|
||||||
setSubmittedAnswers(Object.keys(parsed.answers || {}));
|
: {};
|
||||||
|
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."
|
||||||
|
);
|
||||||
|
}
|
||||||
setToastMessage("Session restored");
|
setToastMessage("Session restored");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Fallback to defaults
|
// Fallback to defaults
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, normal sequential logic checking for progress
|
// Otherwise, normal sequential logic checking for progress
|
||||||
const prog = quiz.progress?.find((p) => p.mode === "SEQUENTIAL");
|
const prog = initialProgress;
|
||||||
if (prog) {
|
if (prog) {
|
||||||
let savedOrder: string[] = [];
|
const restored = normalizeProgress({
|
||||||
try {
|
orderJson: prog.orderJson,
|
||||||
savedOrder = JSON.parse(prog.orderJson);
|
currentIndex: prog.currentIndex,
|
||||||
} catch {
|
dataJson: prog.answersJson,
|
||||||
savedOrder = quiz.questions.map((q) => q.id);
|
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 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 {
|
} else {
|
||||||
const freshOrder = quiz.questions.map((q) => q.id).sort(() => Math.random() - 0.5);
|
const freshOrder = initialQuiz.questions.map((q) => q.id).sort(() => Math.random() - 0.5);
|
||||||
setOrder(freshOrder);
|
setOrder(freshOrder);
|
||||||
|
setAttemptScope(freshOrder);
|
||||||
setCurrentIndex(0);
|
setCurrentIndex(0);
|
||||||
setAnswers({});
|
setAnswers({});
|
||||||
setSubmittedAnswers([]);
|
setSubmittedAnswers([]);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [quiz, retakeIds, isShared]);
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [sessionIdentity, isShared, sharedStorageKey, initialQuiz, initialRetakeIds, initialProgress]);
|
||||||
|
|
||||||
// Persist progress as you go
|
// Persist progress as you go
|
||||||
function saveProgress(
|
function saveProgress(
|
||||||
idx: number,
|
idx: number,
|
||||||
ans: Record<string, string[]>
|
ans: Record<string, string[]>
|
||||||
) {
|
) {
|
||||||
if (retakeIds) return; // Don't persist retake partial state
|
if (attemptScope.length !== quiz.questions.length) return;
|
||||||
|
|
||||||
if (isShared) {
|
if (isShared) {
|
||||||
localStorage.setItem(`quiz_progress_${quiz.id}`, JSON.stringify({
|
localStorage.setItem(sharedStorageKey, JSON.stringify({
|
||||||
currentIndex: idx,
|
currentIndex: idx,
|
||||||
order: order,
|
order: order,
|
||||||
answers: ans,
|
answers: ans,
|
||||||
|
|
@ -145,7 +234,10 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch("/api/progress", {
|
const revision = ++progressRevisionRef.current;
|
||||||
|
saveQueueRef.current = saveQueueRef.current
|
||||||
|
.then(async () => {
|
||||||
|
const response = await fetch("/api/progress", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
@ -153,10 +245,21 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
contentId: quiz.id,
|
contentId: quiz.id,
|
||||||
mode: "SEQUENTIAL",
|
mode: "SEQUENTIAL",
|
||||||
currentIndex: idx,
|
currentIndex: idx,
|
||||||
orderJson: JSON.stringify(order),
|
order,
|
||||||
answersJson: JSON.stringify(ans),
|
answers: ans,
|
||||||
|
sessionId: progressSessionIdRef.current,
|
||||||
|
revision,
|
||||||
}),
|
}),
|
||||||
}).catch(() => {});
|
});
|
||||||
|
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.");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div>Loading quiz...</div>;
|
if (loading) return <div>Loading quiz...</div>;
|
||||||
|
|
@ -217,18 +320,21 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFinish() {
|
async function handleFinish() {
|
||||||
|
if (finishingRef.current) return;
|
||||||
|
finishingRef.current = true;
|
||||||
|
setIsFinishing(true);
|
||||||
|
setFinishError(null);
|
||||||
if (isShared) {
|
if (isShared) {
|
||||||
localStorage.removeItem(`quiz_progress_${quiz.id}`);
|
localStorage.removeItem(sharedStorageKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If shared, grade it locally and show results, don't ping backend
|
// If shared, grade it locally and show results, don't ping backend
|
||||||
if (isShared) {
|
if (isShared) {
|
||||||
// Simulate an attempt object
|
// Simulate an attempt object
|
||||||
let runningScore = 0;
|
let runningScore = 0;
|
||||||
let maxScore = quiz.questions.length; // Actually, depends on retakeIds, but shared doesn't support retakeIds typically
|
const maxScore = attemptScope.length;
|
||||||
if (retakeIds) maxScore = retakeIds.length;
|
|
||||||
|
|
||||||
const scoredItems = (retakeIds || quiz.questions.map(q => q.id)).map(qId => {
|
const scoredItems = attemptScope.map(qId => {
|
||||||
const q = quiz.questions.find((x) => x.id === qId);
|
const q = quiz.questions.find((x) => x.id === qId);
|
||||||
if (!q) return 0;
|
if (!q) return 0;
|
||||||
const formattedQ = {
|
const formattedQ = {
|
||||||
|
|
@ -245,45 +351,40 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
score: runningScore,
|
score: runningScore,
|
||||||
maxScore: maxScore,
|
maxScore: maxScore,
|
||||||
answersJson: JSON.stringify(answers),
|
answersJson: JSON.stringify(answers),
|
||||||
isPartialRetake: false,
|
isPartialRetake: attemptScope.length !== quiz.questions.length,
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
setResultsData(attempt);
|
setResultsData(attempt);
|
||||||
|
finishingRef.current = false;
|
||||||
|
setIsFinishing(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send to backend
|
|
||||||
const isPartialRetake = !!retakeIds;
|
|
||||||
try {
|
try {
|
||||||
|
await saveQueueRef.current;
|
||||||
const res = await fetch(`/api/quizzes/${quiz.id}/attempt`, {
|
const res = await fetch(`/api/quizzes/${quiz.id}/attempt`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
answersJson: JSON.stringify(answers),
|
questionIds: attemptScope,
|
||||||
isPartialRetake,
|
answers,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (!res.ok) {
|
||||||
const attempt = await res.json();
|
const payload = (await res.json().catch(() => null)) as
|
||||||
|
| { error?: string }
|
||||||
// Clear progress
|
| null;
|
||||||
if (!isPartialRetake) {
|
throw new Error(payload?.error ?? "Could not save this attempt");
|
||||||
await fetch("/api/progress", {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
contentType: "QUIZ",
|
|
||||||
contentId: quiz.id,
|
|
||||||
mode: "SEQUENTIAL",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
setResultsData(await res.json());
|
||||||
setResultsData(attempt);
|
} catch (error) {
|
||||||
}
|
setFinishError(
|
||||||
} catch (e) {
|
error instanceof Error ? error.message : "Could not save this attempt"
|
||||||
console.error(e);
|
);
|
||||||
|
} finally {
|
||||||
|
finishingRef.current = false;
|
||||||
|
setIsFinishing(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -295,11 +396,13 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
onRetake={(missedIds: string[]) => {
|
onRetake={(missedIds: string[]) => {
|
||||||
// Restart this component with retakeIds
|
// Restart this component with retakeIds
|
||||||
setOrder(missedIds);
|
setOrder(missedIds);
|
||||||
|
setAttemptScope(missedIds);
|
||||||
setCurrentIndex(0);
|
setCurrentIndex(0);
|
||||||
setAnswers({});
|
setAnswers({});
|
||||||
setSubmittedAnswers([]);
|
setSubmittedAnswers([]);
|
||||||
submittingQuestionRef.current = null;
|
submittingQuestionRef.current = null;
|
||||||
setResultsData(null);
|
setResultsData(null);
|
||||||
|
setFinishError(null);
|
||||||
|
|
||||||
// Re-shuffle options for the new attempt
|
// Re-shuffle options for the new attempt
|
||||||
const newShuffledOpts: Record<string, Option[]> = {};
|
const newShuffledOpts: Record<string, Option[]> = {};
|
||||||
|
|
@ -319,6 +422,11 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center relative">
|
<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 */}
|
{/* Toast Notification */}
|
||||||
{toastMessage && (
|
{toastMessage && (
|
||||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-30 animate-fade-in pointer-events-none">
|
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-30 animate-fade-in pointer-events-none">
|
||||||
|
|
@ -504,12 +612,20 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
Next Question
|
Next Question
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={handleFinish}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
Finish Quiz
|
{isFinishing ? "Saving attempt..." : "Finish Quiz"}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,16 @@ export function SpacedRepetitionSets() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setTimeout(() => void load(), 0);
|
const timer = window.setTimeout(() => void load(), 0);
|
||||||
return () => window.clearTimeout(timer);
|
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);
|
||||||
|
};
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
async function createSet() {
|
async function createSet() {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ export function Navbar() {
|
||||||
const parts = pathname.split("/").filter(Boolean);
|
const parts = pathname.split("/").filter(Boolean);
|
||||||
const classSlug = parts[0] && parts[0] !== "shared" ? parts[0] : null;
|
const classSlug = parts[0] && parts[0] !== "shared" ? parts[0] : null;
|
||||||
const [hasReadyCards, setHasReadyCards] = useState(false);
|
const [hasReadyCards, setHasReadyCards] = useState(false);
|
||||||
|
const [loggingOut, setLoggingOut] = useState(false);
|
||||||
|
const [logoutError, setLogoutError] = useState<string | null>(null);
|
||||||
|
|
||||||
const refreshDueCards = useCallback(async () => {
|
const refreshDueCards = useCallback(async () => {
|
||||||
if (!classSlug) return;
|
if (!classSlug) return;
|
||||||
|
|
@ -53,9 +55,18 @@ export function Navbar() {
|
||||||
}, [refreshDueCards]);
|
}, [refreshDueCards]);
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await fetch("/api/auth/logout", { method: "POST" });
|
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.push("/login");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
setLogoutError(error instanceof Error ? error.message : "Sign out failed. Please retry.");
|
||||||
|
} finally {
|
||||||
|
setLoggingOut(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nav = (
|
const nav = (
|
||||||
|
|
@ -110,7 +121,8 @@ export function Navbar() {
|
||||||
<span className="text-xs font-semibold text-text-muted">Appearance</span>
|
<span className="text-xs font-semibold text-text-muted">Appearance</span>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
<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">
|
{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">
|
||||||
<span aria-hidden>↗</span> Sign out
|
<span aria-hidden>↗</span> Sign out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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