Compare commits
No commits in common. "main" and "codex/newui" have entirely different histories.
main
...
codex/newu
192 changed files with 4107 additions and 20199 deletions
|
|
@ -1,20 +0,0 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
.forgejo
|
||||
.github
|
||||
.env*
|
||||
!.env.example
|
||||
dev.db*
|
||||
study.db*
|
||||
data
|
||||
coverage
|
||||
.test-databases
|
||||
.npm-cache
|
||||
.prisma-cache
|
||||
audit-results
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
out.css
|
||||
temp.css
|
||||
test.css
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
name: Verify and publish container
|
||||
name: Automated Container Build
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -11,60 +11,15 @@ jobs:
|
|||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# The changed-file lint step compares the pushed commit with its parent.
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Log into Local Registry
|
||||
run: |
|
||||
echo "${{ secrets.FORGEJO_PAT }}" | docker login git.elijahkuntz.com -u "${{ gitea.actor }}" --password-stdin
|
||||
|
||||
- name: Install locked dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Validate Prisma schema and migration drift
|
||||
- name: Build and Push Image
|
||||
run: |
|
||||
npx prisma validate
|
||||
DATABASE_URL=file:./ci-migration.test.db npx prisma migrate deploy
|
||||
npx prisma migrate diff --from-migrations prisma/migrations --to-schema prisma/schema.prisma --exit-code
|
||||
# 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:]')
|
||||
|
||||
- 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"
|
||||
docker build -t "$IMAGE_PATH" .
|
||||
docker push "$IMAGE_PATH"
|
||||
2
.gitattributes
vendored
2
.gitattributes
vendored
|
|
@ -1,2 +0,0 @@
|
|||
# Prisma 7 generated doc comments contain trailing spaces; do not hand-edit generated output.
|
||||
src/generated/prisma/** -whitespace
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -12,18 +12,6 @@
|
|||
|
||||
# testing
|
||||
/coverage
|
||||
/.test-databases/
|
||||
|
||||
# local SQLite study data
|
||||
/dev.db
|
||||
/dev.db-journal
|
||||
/dev.db-shm
|
||||
/dev.db-wal
|
||||
/data/
|
||||
/study.db
|
||||
/study.db-journal
|
||||
/study.db-shm
|
||||
/study.db-wal
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
|
|
|||
559
AGENTS.md
559
AGENTS.md
|
|
@ -1,242 +1,340 @@
|
|||
# Study Desk - AI Contributor Guide
|
||||
|
||||
This file is the operating guide for AI agents and human contributors working in this repository.
|
||||
This file is the project-specific operating guide for AI agents and human contributors working in this repository.
|
||||
|
||||
## Project summary
|
||||
## Project overview
|
||||
|
||||
Study Desk is a self-hosted, single-user study application built with Next.js, React, TypeScript, Prisma, and SQLite.
|
||||
Study Desk is a self-hosted, single-user study application. It organizes study material by class and supports:
|
||||
|
||||
Core capabilities:
|
||||
- Flashcard decks with Markdown content, self-grading, shuffle persistence, resume state, and card management.
|
||||
- Practice quizzes with multiple-choice and SATA questions, scoring, rationales, category breakdowns, retakes, and attempt history.
|
||||
- Importing validated JSON files and generating importable JSON from user-provided study material.
|
||||
- Material groups for organizing decks and quizzes.
|
||||
- Password-protected private pages with public, read-only share links for decks, quizzes, and groups.
|
||||
- Light and dark themes with responsive layouts for desktop and mobile use.
|
||||
|
||||
- Flashcard decks with Markdown content, self-grading, shuffle persistence, resume state, and card management
|
||||
- Practice quizzes with multiple-choice and SATA questions, partial-credit scoring, rationales, category breakdowns, retakes, and attempt history
|
||||
- JSON import, repair, validation, and generation workflows
|
||||
- Material groups for organizing decks and quizzes
|
||||
- Password-protected private pages
|
||||
- Public, read-only share links for decks, quizzes, and groups
|
||||
- Responsive light and dark themes
|
||||
The application is intentionally a small single-user container rather than a multi-tenant SaaS product. There is no `User` model. Authentication state is held in an encrypted `iron-session` cookie, and content ownership is implicit in the one local installation.
|
||||
|
||||
This is intentionally a single-user container, not a multi-tenant SaaS application. There is no `User` model. Content ownership is implicit in the local installation.
|
||||
When documentation conflicts with implementation, prefer the current source code and `prisma/schema.prisma` over the older planning document or the stock README.
|
||||
|
||||
When documentation conflicts with implementation, prefer:
|
||||
## Mandatory project rules
|
||||
|
||||
1. Current source code
|
||||
2. `prisma/schema.prisma`
|
||||
3. Applied migrations
|
||||
4. This file
|
||||
5. Older planning documents and the stock README
|
||||
### Next.js version and documentation
|
||||
|
||||
## Mandatory working rules
|
||||
This repository uses Next.js `16.2.9`, React `19.2.4`, and the App Router. This is not a generic Next.js project. Before changing Next.js routing, layouts, dynamic parameters, server/client boundaries, middleware, or framework APIs:
|
||||
|
||||
- Inspect `git status --short` before editing.
|
||||
- Preserve unrelated user changes.
|
||||
- Keep changes scoped to the requested behavior.
|
||||
- Do not use destructive commands such as `git reset --hard` or `git checkout --` unless explicitly requested.
|
||||
- Prefer narrow, patch-based edits over rewriting entire files.
|
||||
1. Read the relevant guide under `node_modules/next/dist/docs/`.
|
||||
2. Follow the version installed in this repository, not a remembered API from an older Next.js version.
|
||||
3. Pay attention to deprecation notices in build output. The current project still uses `src/middleware.ts`; Next.js currently warns that the `middleware` convention is moving toward `proxy`. Do not perform that migration as unrelated cleanup.
|
||||
|
||||
Relevant local references include:
|
||||
|
||||
- `node_modules/next/dist/docs/01-app/01-getting-started/05-server-and-client-components.md`
|
||||
- `node_modules/next/dist/docs/01-app/01-getting-started/04-linking-and-navigating.md`
|
||||
- `node_modules/next/dist/docs/01-app/03-api-reference/01-directives/use-client.md`
|
||||
- `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/route.md`
|
||||
- `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/dynamic-routes.md`
|
||||
|
||||
### General contribution behavior
|
||||
|
||||
- Ask a clarifying question before proceeding when missing information would materially change the implementation or create a risky assumption. Otherwise make a narrow, documented assumption and continue.
|
||||
- Preserve existing user changes. Inspect `git status` before editing and do not use destructive commands such as `git reset --hard` or `git checkout --` unless explicitly requested.
|
||||
- Use `apply_patch` for source and documentation edits.
|
||||
- Keep changes scoped to the requested behavior. Do not rewrite unrelated components, migrate frameworks, or clean up the whole repository during a feature fix.
|
||||
- Do not commit secrets, `.env` files, database contents, generated credentials, or private study material.
|
||||
- Prefer TypeScript types over `any`, even though some existing files have `any` and the current lint baseline is not clean.
|
||||
- Do not manually edit generated Prisma files under `src/generated/prisma/`.
|
||||
- Do not modify an already-applied migration. Change `prisma/schema.prisma`, then create a new migration.
|
||||
- Prefer TypeScript types over `any`.
|
||||
- Add dependencies only when the existing stack cannot reasonably solve the problem.
|
||||
- When dependencies change, update `package-lock.json` and verify the build.
|
||||
- Separate UI, route handling, services, and low-level utilities by responsibility.
|
||||
- Ask a clarifying question only when missing information would materially change the implementation or create a risky assumption.
|
||||
- Do not manually edit an already-applied migration. Change `prisma/schema.prisma`, then create a new migration.
|
||||
- Add dependencies only when the existing stack cannot reasonably solve the problem. If dependencies change, update `package-lock.json` and verify the build.
|
||||
- Keep route handlers, services, and UI components separated by responsibility as described below.
|
||||
|
||||
## Stack and source of truth
|
||||
## Stack and repository configuration
|
||||
|
||||
Confirm exact versions in `package.json`. The project currently uses:
|
||||
| Area | Current implementation |
|
||||
| --- | --- |
|
||||
| Language | TypeScript, strict mode enabled |
|
||||
| UI | React 19 with Next.js 16 App Router |
|
||||
| Styling | Tailwind CSS v4 through `src/app/globals.css` and `@tailwindcss/postcss` |
|
||||
| Fonts | `Manrope` for interface text and `Newsreader` for editorial headings via `next/font/google` |
|
||||
| Persistence | SQLite with Prisma 7 and `@prisma/adapter-better-sqlite3` |
|
||||
| Authentication | `iron-session` cookie, Argon2 password hashing, in-memory login rate limiter |
|
||||
| Validation | Zod schemas in `src/lib/validation/` |
|
||||
| Markdown | `react-markdown` with `remark-gfm` |
|
||||
| Drag and drop | `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities` |
|
||||
| JSON repair | `jsonrepair` through `src/lib/jsonRepair.ts` |
|
||||
| Runtime | Node 22-slim in the Docker image; local Node/npm should be compatible with the lockfile |
|
||||
| Deployment | Next standalone output in a single Docker container |
|
||||
|
||||
- Next.js 16 App Router
|
||||
- React 19
|
||||
- TypeScript in strict mode
|
||||
- Tailwind CSS v4
|
||||
- Prisma 7 with SQLite and `@prisma/adapter-better-sqlite3`
|
||||
- `iron-session` for encrypted session cookies
|
||||
- Argon2 password hashing
|
||||
- Zod validation
|
||||
- `react-markdown` with `remark-gfm`
|
||||
- `@dnd-kit` for sortable UI
|
||||
- Node 22 in Docker
|
||||
Important configuration files:
|
||||
|
||||
Important files:
|
||||
- `package.json` - scripts and dependency versions.
|
||||
- `next.config.ts` - standalone output and `better-sqlite3` as a server external package.
|
||||
- `tsconfig.json` - strict TypeScript, bundler module resolution, and `@/*` mapped to `src/*`.
|
||||
- `eslint.config.mjs` - Next core-web-vitals and Next TypeScript rules.
|
||||
- `postcss.config.mjs` - Tailwind v4 PostCSS integration.
|
||||
- `prisma.config.ts` - Prisma schema and SQLite URL resolution.
|
||||
- `Dockerfile`, `docker-compose.yml`, `docker-compose.override.yml`, `docker-entrypoint.sh` - container build and runtime.
|
||||
|
||||
- `package.json` - scripts and dependency versions
|
||||
- `prisma/schema.prisma` - database source of truth
|
||||
- `src/lib/db.ts` - shared Prisma client
|
||||
- `src/lib/auth.ts` - session helpers
|
||||
- `src/services/` - business logic and database access
|
||||
- `src/app/api/` - route handlers
|
||||
- `src/app/globals.css` - design tokens and global styling
|
||||
- `src/config/studyModes.ts` - shared study-mode navigation
|
||||
- `SKILL.md` - project workflow for adding API routes
|
||||
- `study-app-implementation-plan.md` - historical architectural context only
|
||||
- `README.md` - currently not authoritative
|
||||
## Commands and local setup
|
||||
|
||||
For framework-sensitive changes involving routing, layouts, dynamic params, middleware, server/client boundaries, or framework APIs, follow nearby repository patterns and consult version-matched Next.js documentation. Do not perform unrelated framework migrations.
|
||||
### Install and run locally
|
||||
|
||||
## Local development
|
||||
|
||||
For a clean checkout:
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm install
|
||||
npx prisma generate
|
||||
npm run dev
|
||||
```
|
||||
|
||||
On Windows PowerShell, use `npm.cmd` and `npx.cmd` if script execution policy blocks the default shims.
|
||||
Then open `http://localhost:3000`.
|
||||
|
||||
Useful scripts:
|
||||
On Windows PowerShell, this environment may block the `npm.ps1` and `npx.ps1` shims because of execution policy. Use `npm.cmd` and `npx.cmd` when necessary:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run start
|
||||
npm run lint
|
||||
```powershell
|
||||
npm.cmd install
|
||||
npx.cmd prisma generate
|
||||
npm.cmd run dev
|
||||
```
|
||||
|
||||
There is currently no configured test runner. Use focused manual verification plus `npm run build`. Run lint as well, but distinguish pre-existing failures from failures introduced by the current change.
|
||||
The default local database is `dev.db` when `DATABASE_URL` is `file:./dev.db`. There is no seed script; classes and study material are created through the UI or API.
|
||||
|
||||
## Environment and authentication
|
||||
### Environment variables
|
||||
|
||||
Common environment variables:
|
||||
Copy `.env.example` to `.env` for local work. The current variables are:
|
||||
|
||||
- `DATABASE_URL` - SQLite URL
|
||||
- `SESSION_SECRET` - `iron-session` secret, at least 32 characters outside throwaway local development
|
||||
- `SECURE_COOKIES` - marks the session cookie secure when set to `true`
|
||||
- `ADMIN_PASSWORD_HASH` - present in `.env.example`, but currently not used by the login flow
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `DATABASE_URL` | Prisma SQLite URL. Local default: `file:./dev.db`. |
|
||||
| `SESSION_SECRET` | Secret used to encrypt the `iron-session` cookie. Use a random value of at least 32 characters outside local throwaway development. |
|
||||
| `ADMIN_PASSWORD_HASH` | Present in `.env.example`, but the current login implementation does not read it. See authentication below. |
|
||||
| `SECURE_COOKIES` | If set to `true` in production, the session cookie is marked `secure`. |
|
||||
|
||||
Current password behavior:
|
||||
The `.env` file is ignored by Git. Never paste real secrets into source files, documentation, logs, or commits.
|
||||
|
||||
1. On first login, the submitted password is Argon2-hashed.
|
||||
2. The hash is stored in the `Setting` row `admin_password_hash`.
|
||||
3. Later logins verify against that stored hash.
|
||||
### Password initialization behavior
|
||||
|
||||
Do not change password provisioning casually. Any change must update login, setup-status behavior, deployment documentation, and security handling together.
|
||||
The current implementation initializes the password on the first login when the `Setting` row `admin_password_hash` does not exist:
|
||||
|
||||
Authentication state is stored in the `study-app-session` cookie.
|
||||
1. The first password submitted at `/login` is Argon2-hashed and stored in the `Setting` table.
|
||||
2. Later logins verify against that stored hash.
|
||||
3. `ADMIN_PASSWORD_HASH` is currently not wired into this flow despite appearing in `.env.example` and the planning document.
|
||||
|
||||
Public paths include:
|
||||
Do not change this behavior casually. If environment-driven password provisioning is added, update the login route, setup-status route, deployment documentation, and security handling together.
|
||||
|
||||
- `/login`
|
||||
- `/api/auth/*`
|
||||
- `/shared/*`
|
||||
- Static assets and Next internals
|
||||
### Available scripts
|
||||
|
||||
Everything else requires authentication unless explicitly designed and reviewed as public.
|
||||
```bash
|
||||
npm run dev # Next development server
|
||||
npm run build # Production build; also runs TypeScript checking
|
||||
npm run start # Start the built app locally
|
||||
npm run lint # ESLint
|
||||
```
|
||||
|
||||
Do not log passwords, secrets, full share tokens, or private study content.
|
||||
There is currently no test runner configured in `package.json`. Use the production build plus focused manual/browser verification for UI changes.
|
||||
|
||||
## Database and migration safety
|
||||
### Database workflow
|
||||
|
||||
The local `dev.db` may contain real study data.
|
||||
|
||||
- Do not delete, reset, replace, or recreate it during normal work.
|
||||
- Back up the database before destructive migration work.
|
||||
- Use the shared Prisma client from `@/lib/db`.
|
||||
- Do not create additional `PrismaClient` instances in request handlers or components.
|
||||
- Use transactions when partial writes would leave inconsistent state.
|
||||
- Return 404 responses for missing records instead of exposing raw Prisma errors.
|
||||
|
||||
After changing the Prisma schema:
|
||||
After changing `prisma/schema.prisma`:
|
||||
|
||||
```bash
|
||||
npx prisma generate
|
||||
npx prisma migrate dev --name describe_the_change
|
||||
```
|
||||
|
||||
Use `npx prisma migrate deploy` for deployed environments.
|
||||
Use `npx prisma migrate deploy` for an existing deployment. The Docker entrypoint runs `prisma migrate deploy` before starting the server.
|
||||
|
||||
## Architecture conventions
|
||||
Be careful with `dev.db`: it may contain the developer's actual study data. Do not delete or reset it as part of normal feature work. Inspect migration status and make a backup before any destructive database operation.
|
||||
|
||||
## Docker details
|
||||
|
||||
The production image is a three-stage Node 22-slim build:
|
||||
|
||||
1. Install dependencies.
|
||||
2. Generate Prisma and run `next build`.
|
||||
3. Run the Next standalone server with SQLite data mounted at `/app/data`.
|
||||
|
||||
The runtime image sets `DATABASE_URL=file:/app/data/study.db` and `PORT=3726`. `docker-entrypoint.sh` applies migrations and then starts `server.js`.
|
||||
|
||||
Known compose detail: `docker-compose.yml` currently publishes `3000:3000`, while the production Dockerfile sets the runtime port to `3726`. Verify or correct that mapping before relying on the production compose file. `docker-compose.override.yml` is a development override that runs `npm run dev` and uses port 3000.
|
||||
|
||||
## Current project structure
|
||||
|
||||
The important source tree is:
|
||||
|
||||
```text
|
||||
prisma/
|
||||
schema.prisma # SQLite/Prisma source of truth
|
||||
migrations/ # Applied database migrations
|
||||
|
||||
src/
|
||||
app/
|
||||
layout.tsx # Root metadata, fonts, theme bootstrap
|
||||
globals.css # Tailwind v4, design tokens, global styles
|
||||
icon.svg
|
||||
login/page.tsx # Public login/setup screen
|
||||
(protected)/
|
||||
layout.tsx # Session check and authenticated navbar shell
|
||||
page.tsx # Authenticated class library
|
||||
[classSlug]/
|
||||
layout.tsx # Class lookup, header, and study-mode tabs
|
||||
page.tsx # Redirects to flashcards
|
||||
flashcards/page.tsx # Grouped deck library and drag/drop ordering
|
||||
flashcards/[deckId]/page.tsx
|
||||
# Deck study/manage screen
|
||||
quizzes/page.tsx # Grouped quiz library and drag/drop ordering
|
||||
quizzes/[quizId]/page.tsx
|
||||
# Quiz taking/history screen
|
||||
shared/[classSlug]/[type]/[token]/
|
||||
page.tsx # Public read-only share route
|
||||
SharedViewer.tsx # Public deck/quiz viewer
|
||||
SharedGroupViewer.tsx # Public group selector/viewer
|
||||
api/
|
||||
.../route.ts # Route handlers; see API map below
|
||||
|
||||
components/
|
||||
flashcards/ # Flashcard viewer, manager, and list
|
||||
quizzes/ # Quiz viewer, results, history, breakdown
|
||||
import/ # Import modal and Create/Import/Generate tabs
|
||||
ui/ # Navbar, class header/tabs, theme, share menu
|
||||
|
||||
services/
|
||||
classService.ts
|
||||
deckService.ts
|
||||
cardService.ts
|
||||
quizService.ts
|
||||
progressService.ts
|
||||
shareService.ts
|
||||
settingsService.ts # Persistence for LLM instruction templates
|
||||
|
||||
lib/
|
||||
db.ts # Prisma singleton with better-sqlite3 adapter
|
||||
auth.ts # iron-session helpers
|
||||
rateLimiter.ts # In-memory login sliding-window limiter
|
||||
scoring.ts # Quiz scoring
|
||||
shuffle.ts # Stable order/session helpers
|
||||
jsonRepair.ts # JSON parse/repair pipeline
|
||||
validation/importSchemas.ts
|
||||
|
||||
config/studyModes.ts # Central list of flashcards/quizzes modes
|
||||
middleware.ts # Auth gate and public-path exceptions
|
||||
generated/prisma/ # Generated Prisma client; do not hand edit
|
||||
|
||||
public/ # Static assets
|
||||
```
|
||||
|
||||
`(protected)` is a route group and is not part of the URL. The `classSlug`, `deckId`, `quizId`, and share `token` segments are dynamic route parameters.
|
||||
|
||||
## Page routes and API surface
|
||||
|
||||
### Page routes
|
||||
|
||||
| Route | Behavior |
|
||||
| --- | --- |
|
||||
| `/login` | Public password setup/login page. |
|
||||
| `/` | Authenticated class library. |
|
||||
| `/<classSlug>` | Redirects to `/<classSlug>/flashcards`. |
|
||||
| `/<classSlug>/flashcards` | Grouped flashcard deck library. |
|
||||
| `/<classSlug>/flashcards/<deckId>` | Flashcard study or card-management view. |
|
||||
| `/<classSlug>/quizzes` | Grouped quiz library. |
|
||||
| `/<classSlug>/quizzes/<quizId>` | Quiz-taking or attempt-history view. |
|
||||
| `/shared/<classSlug>/flashcards/<token>` | Public shared deck. |
|
||||
| `/shared/<classSlug>/quizzes/<token>` | Public shared quiz. |
|
||||
| `/shared/<classSlug>/groups/<token>` | Public shared group; `?itemId=...` can select an item. |
|
||||
|
||||
### API routes
|
||||
|
||||
All API routes are behind `src/middleware.ts` unless they are under `/api/auth`. The API is currently consumed by client components with `fetch`.
|
||||
|
||||
| Endpoint | Methods and purpose |
|
||||
| --- | --- |
|
||||
| `/api/auth/login` | `POST` login, first-time password setup, rate limiting, progressive lockout. |
|
||||
| `/api/auth/logout` | `POST` destroy the session. |
|
||||
| `/api/auth/setup-status` | `GET` whether the password setting exists. |
|
||||
| `/api/classes` | `GET` list classes; `POST` create class. |
|
||||
| `/api/classes/[id]` | `PATCH` rename; `DELETE` class and cascaded content. |
|
||||
| `/api/decks` | `POST` validate and import a deck. |
|
||||
| `/api/decks/list` | `GET` decks for `classId`. |
|
||||
| `/api/decks/[id]` | `GET` deck with cards; `PATCH` rename/description; `DELETE`. |
|
||||
| `/api/decks/[id]/cards` | `POST` append a card. |
|
||||
| `/api/cards/[id]` | `PATCH` edit card; `DELETE` card. |
|
||||
| `/api/decks/reorder` | `PATCH` persist deck order/group placement. |
|
||||
| `/api/quizzes` | `POST` validate and import a quiz. |
|
||||
| `/api/quizzes/list` | `GET` quizzes for `classId`. |
|
||||
| `/api/quizzes/[id]` | `GET` quiz with questions/options; `PATCH` rename/description; `DELETE`. |
|
||||
| `/api/quizzes/[id]/attempt` | `GET` attempt history; `POST` score and save an attempt. |
|
||||
| `/api/quizzes/reorder` | `PATCH` persist quiz order/group placement. |
|
||||
| `/api/material-groups` | `GET` groups by `classId` and optional `type`; `POST` create group. |
|
||||
| `/api/material-groups/[id]` | `PATCH` rename/group metadata; `DELETE` group. |
|
||||
| `/api/progress` | `GET`, `PATCH`, and `DELETE` resume progress for deck/quiz and mode. |
|
||||
| `/api/settings/llm-instructions` | `GET`/`PATCH` saved generation instructions by `type`; `__RESET__` restores defaults. |
|
||||
| `/api/share` | `GET` share state; `POST` toggle a deck, quiz, or group link. |
|
||||
|
||||
## Architecture and coding conventions
|
||||
|
||||
### Server and client components
|
||||
|
||||
- Use Server Components by default.
|
||||
- Add `"use client"` only when state, effects, event handlers, browser APIs, drag and drop, or client-side fetching are required.
|
||||
- Keep database and authentication modules out of client import graphs.
|
||||
- Do not access `window`, `localStorage`, `navigator`, or clipboard APIs from Server Components.
|
||||
- Prefer `Link` for ordinary internal navigation.
|
||||
- Use `useRouter` when navigation follows an action or must be imperative.
|
||||
- Follow nearby Next.js 16 patterns for dynamic params and route context types.
|
||||
- Use Server Components by default for layouts and pages that only fetch or compose data.
|
||||
- Add `"use client"` only where state, event handlers, effects, browser APIs, drag/drop, or client-side fetches are needed.
|
||||
- Once a file is a Client Component, its directly imported module graph is included in the client bundle. Keep server-only database/auth code out of client imports.
|
||||
- Do not access `window`, `localStorage`, `navigator`, or the clipboard API from a Server Component.
|
||||
- Dynamic server route params use the current Next.js 16 conventions in this repository. Follow nearby examples such as `src/app/(protected)/[classSlug]/layout.tsx` and API `RouteContext` types instead of inventing older parameter types.
|
||||
- Prefer `Link` for normal internal navigation. Use `useRouter` only when navigation follows an action or requires imperative behavior.
|
||||
|
||||
### API routes and services
|
||||
### API handlers and services
|
||||
|
||||
Preferred route-handler flow:
|
||||
The preferred API route shape is:
|
||||
|
||||
1. Parse and validate external input.
|
||||
2. Delegate business logic to focused service or utility functions.
|
||||
3. Return a useful status and JSON response.
|
||||
1. Parse and validate request input.
|
||||
2. Call one function in the matching service or low-level utility.
|
||||
3. Return a `NextResponse` with a useful status and JSON body.
|
||||
|
||||
Business logic and Prisma calls normally belong in `src/services/` or a focused `src/lib/` module. Reuse existing services before creating new abstractions.
|
||||
Business logic and Prisma calls belong in `src/services/` or a focused `src/lib/` utility. Reuse an existing service before creating a new one. `SKILL.md` contains the project-specific workflow for adding an API route.
|
||||
|
||||
Some older routes still contain direct Prisma calls or loose input handling. Improve the requested path without turning a small task into a broad refactor.
|
||||
A few older routes still contain direct Prisma calls or loose input handling. Follow the preferred pattern for new work without broad refactoring unless the task specifically requests it.
|
||||
|
||||
### Validation
|
||||
### Prisma and database access
|
||||
|
||||
- Reuse existing Zod schemas where available.
|
||||
- Never rely only on client-side validation.
|
||||
- Validate IDs, required strings, enums, arrays, and import payloads at the API boundary.
|
||||
- Keep stored JSON parseable.
|
||||
- Use `jsonRepair.ts` only for user- or LLM-generated import text, not to hide malformed database state.
|
||||
- Import the shared client from `@/lib/db`; do not instantiate a new `PrismaClient` in a request or component.
|
||||
- Prisma uses the generated client at `src/generated/prisma`, configured by `prisma/schema.prisma`.
|
||||
- Keep database names and relation behavior consistent with the schema: `Deck` and `QuizSet` belong to a `Class`; cards/questions/options cascade from their parent; group deletion sets item `groupId` to null; share links and progress cascade with their content.
|
||||
- Use transactions for multi-row reorder/update operations where partial writes would be harmful.
|
||||
- Handle missing records as 404s at the route boundary rather than leaking Prisma exceptions.
|
||||
|
||||
## UI and accessibility
|
||||
### Validation and input handling
|
||||
|
||||
Use the existing design system in `src/app/globals.css`.
|
||||
- Reuse `flashcardImportSchema` and `quizImportSchema` from `src/lib/validation/importSchemas.ts` for import payloads on both client preview and server commit.
|
||||
- Do not trust client-side validation alone.
|
||||
- Validate IDs, enum-like values (`DECK`/`QUIZ`, `SEQUENTIAL`/`SHUFFLED`, question types), required strings, and array shapes at the API boundary.
|
||||
- Keep stored JSON fields parseable and handle malformed or stale JSON defensively. `jsonRepair.ts` is for repairing user/LLM-generated import text, not for hiding malformed database state.
|
||||
|
||||
Common tokens include:
|
||||
### UI and design system
|
||||
|
||||
- `bg-bg-base`
|
||||
- `bg-bg-surface`
|
||||
- `bg-bg-surface-alt`
|
||||
- `text-text-heading`
|
||||
- `text-text-secondary`
|
||||
- `text-text-muted`
|
||||
- `primary`
|
||||
- `primary-hover`
|
||||
- `border`
|
||||
- `border-light`
|
||||
- `success`
|
||||
- `error`
|
||||
- Use the CSS variables and Tailwind aliases defined in `src/app/globals.css`: `bg-bg-base`, `bg-bg-surface`, `bg-bg-surface-alt`, `text-text-heading`, `text-text-secondary`, `text-text-muted`, `primary`, `primary-hover`, `border`, `border-light`, `success`, and `error`.
|
||||
- Preserve both light and dark theme behavior. Avoid introducing one-off colors when an existing token is appropriate.
|
||||
- Use `editorial-title` for major serif headings, `font-sans`/Manrope for interface text, and the existing card/modal shadows and rounded-corner language.
|
||||
- Maintain responsive behavior at mobile widths. Study pages, library cards, modals, and action rows must remain usable without horizontal overflow.
|
||||
- Use visible focus states, descriptive `title`/`aria-label` text for icon-only buttons, semantic buttons/links, and keyboard-accessible controls.
|
||||
- Keep Markdown rendering consistent with the existing `ReactMarkdown` + `remarkGfm` usage and `.markdown-content` styles.
|
||||
- Use the existing `@dnd-kit` patterns when changing sortable cards or groups. Do not attach drag listeners to nested action buttons.
|
||||
|
||||
Preserve:
|
||||
|
||||
- Light and dark themes
|
||||
- Responsive mobile layouts
|
||||
- Existing card, modal, shadow, and rounded-corner language
|
||||
- `editorial-title` for major serif headings
|
||||
- Manrope for interface text
|
||||
- Existing Markdown rendering through `ReactMarkdown`, `remarkGfm`, and `.markdown-content`
|
||||
- Existing `@dnd-kit` patterns for sortable content
|
||||
|
||||
Accessibility requirements:
|
||||
|
||||
- Use semantic buttons and links.
|
||||
- Preserve visible keyboard focus states.
|
||||
- Give icon-only controls an accessible name with visible text or `aria-label`.
|
||||
- Keep interactive controls keyboard accessible.
|
||||
- Avoid horizontal overflow on study pages, library cards, modals, and action rows.
|
||||
- Do not attach drag listeners to nested action buttons.
|
||||
|
||||
## Domain invariants
|
||||
## Domain behavior to preserve
|
||||
|
||||
### Flashcards
|
||||
|
||||
- A deck contains ordered `Flashcard` rows with `front` and `back`.
|
||||
- Study progress stores order and results as JSON.
|
||||
- Study mode may be sequential or shuffled.
|
||||
- Saved progress may contain stale card IDs after deletion. Resume helpers must filter and clamp stale IDs safely.
|
||||
- Preserve both study and manage views.
|
||||
- Preserve existing sharing entry points when adding new ones.
|
||||
- A deck contains ordered `Flashcard` rows with `front` and `back` strings.
|
||||
- Study progress is stored in `StudyProgress` as JSON order/results and can be sequential or shuffled.
|
||||
- Deleting a card can leave a stale ID in saved `orderJson`; the shuffle/session helpers filter and clamp stale IDs when resuming.
|
||||
- The deck page supports study and manage views. Keep the existing viewer-header share action when adding other share entry points.
|
||||
|
||||
### Quizzes
|
||||
|
||||
- A quiz contains ordered questions with ordered answer options.
|
||||
- A quiz contains ordered questions, each with ordered answer options.
|
||||
- `MULTIPLE_CHOICE` questions must have exactly one correct option.
|
||||
- `SATA` questions may have multiple correct options.
|
||||
- SATA uses partial-credit scoring in `src/lib/scoring.ts`.
|
||||
- Partial retakes score only questions present in the submitted answer set.
|
||||
- Attempts persist score, maximum score, submitted answers, retake state, and completion time.
|
||||
- In-progress answers and position persist through `/api/progress`.
|
||||
- `SATA` questions may have multiple correct options and use partial credit in `src/lib/scoring.ts`.
|
||||
- Attempts are persisted as `QuizAttempt` rows with score, maximum score, answer JSON, retake flag, and completion time.
|
||||
- Partial retakes score only the questions included in the submitted answer set.
|
||||
- The quiz viewer persists in-progress answers and position through `/api/progress`.
|
||||
|
||||
### Imports and generation
|
||||
|
||||
|
|
@ -270,81 +368,112 @@ Quiz import shape:
|
|||
}
|
||||
```
|
||||
|
||||
The app does not call an LLM directly. The Generate UI prepares instructions and accepts generated JSON from the user.
|
||||
|
||||
Preserve Markdown inside JSON string values and render it through existing Markdown components.
|
||||
|
||||
Keep the current separation between `ImportModal`, `CreateTab`, `ImportTab`, and `GenerateTab`.
|
||||
- Import UI is split into `ImportModal`, `CreateTab`, `ImportTab`, and `GenerateTab`. Keep that separation rather than creating one large modal component.
|
||||
- Generation instructions are stored in the `Setting` table and are editable/resettable through `/api/settings/llm-instructions`. The app does not call an LLM itself; the Generate tab prepares instructions and accepts generated JSON from the user.
|
||||
- Generated text may contain Markdown inside JSON string values. Preserve it and render it safely through the existing Markdown components.
|
||||
|
||||
### Sharing
|
||||
|
||||
- `ShareLink` may target a `DECK`, `QUIZ`, or `GROUP`.
|
||||
- Reuse `src/components/ui/ShareMenu.tsx`.
|
||||
- Public routes are `/shared/<classSlug>/<type>/<token>`.
|
||||
- Public viewers are read-only.
|
||||
- Public viewers must not write authenticated progress.
|
||||
- Validate that token, type, and class slug agree before returning shared content.
|
||||
- A deck or quiz may appear publicly shared through its group. Preserve inherited group-share behavior and `?itemId=...` handling.
|
||||
- `ShareLink` rows use UUID IDs as public tokens and can target a `DECK`, `QUIZ`, or `GROUP`.
|
||||
- `src/components/ui/ShareMenu.tsx` is the shared UI for loading share state, toggling a link, and copying the public URL. Reuse it rather than duplicating share URL or group-inheritance logic.
|
||||
- Public URLs are `/shared/<classSlug>/<type>/<token>`, where item types are `flashcards`, `quizzes`, and `groups`.
|
||||
- A deck or quiz can appear publicly shared through its group. The API returns the group token and the UI includes `?itemId=...` when copying a link to an item in a shared group.
|
||||
- Shared viewers are read-only and should not write authenticated progress. Preserve the distinction between protected study viewers and public shared viewers.
|
||||
|
||||
## Data model summary
|
||||
|
||||
The source of truth is `prisma/schema.prisma`:
|
||||
|
||||
- `Class` - named study space with a unique slug and sort order.
|
||||
- `Deck` - flashcard collection belonging to a class, optionally assigned to a `MaterialGroup`.
|
||||
- `Flashcard` - ordered front/back card belonging to a deck.
|
||||
- `QuizSet` - quiz collection belonging to a class, optionally assigned to a `MaterialGroup`.
|
||||
- `Question` - ordered quiz prompt, type, rationale, and category.
|
||||
- `AnswerOption` - ordered answer option with `isCorrect`.
|
||||
- `StudyProgress` - resumable deck/quiz state. Unique by parent and mode.
|
||||
- `QuizAttempt` - completed quiz score and submitted answer JSON.
|
||||
- `MaterialGroup` - typed (`DECK` or `QUIZ`) class grouping for library organization.
|
||||
- `ShareLink` - optional one-to-one link for a deck, quiz, or group.
|
||||
- `Setting` - key/value storage for the admin password hash and editable LLM instructions.
|
||||
- `AuthSecurity` - singleton lockout/rate-limit state persisted in SQLite.
|
||||
|
||||
Most parent relations use cascading deletion. Treat deletion of a class, deck, quiz, question, or group as a data-affecting operation and preserve confirmation behavior in the UI.
|
||||
|
||||
## Authentication and security boundaries
|
||||
|
||||
`src/middleware.ts` allows these paths without a session:
|
||||
|
||||
- `/login`
|
||||
- `/api/auth/*`
|
||||
- `/shared/*`
|
||||
- Static assets and Next internals
|
||||
|
||||
Everything else requires the `study-app-session` cookie. Individual API routes generally rely on middleware for the authenticated boundary, so a new public endpoint must be an explicit, reviewed decision.
|
||||
|
||||
The login route has two protections:
|
||||
|
||||
- An in-memory per-IP sliding-window limiter: 10 attempts per minute.
|
||||
- Progressive lockout stored in `AuthSecurity`: one minute after 5 failures, five minutes after 10, thirty minutes after 15, and twenty-four hours after 20.
|
||||
|
||||
Do not log passwords, session secrets, full share tokens, or private study content. Treat public share routes as intentionally public and validate that the token, type, and class slug agree before returning content.
|
||||
|
||||
## Verification checklist
|
||||
|
||||
Before editing:
|
||||
### Before editing
|
||||
|
||||
- Read `git status --short`.
|
||||
- Inspect the target component, route, service, and schema paths relevant to the requested behavior.
|
||||
- Check whether the change affects authentication, public sharing, migrations, generated Prisma output, or real study data.
|
||||
- Inspect the target page/component, its API route, and its service before changing behavior.
|
||||
- Read the relevant Next.js local guide if framework behavior is involved.
|
||||
- Check whether the change affects the Prisma schema, migrations, auth boundary, public sharing, or generated client.
|
||||
|
||||
During implementation:
|
||||
### During implementation
|
||||
|
||||
- Keep route, service, utility, and component responsibilities clear.
|
||||
- Keep the route/service/component boundaries intact.
|
||||
- Add validation for new external input.
|
||||
- Reuse existing design tokens, components, and services.
|
||||
- Consider loading, error, empty, mobile, keyboard, light-theme, and dark-theme states.
|
||||
- Avoid unrelated cleanup.
|
||||
- Reuse existing design tokens, shared components, and services.
|
||||
- Consider loading, error, empty, mobile, keyboard, and both theme states.
|
||||
- Avoid synchronous state updates directly inside effects when a state initializer, derived value, event handler, or async callback is more appropriate. The current ESLint configuration reports these patterns.
|
||||
|
||||
After editing:
|
||||
### After editing
|
||||
|
||||
Run the narrowest relevant checks, then the full build when possible:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
If lint already fails, report baseline failures separately.
|
||||
If lint is already failing, report baseline failures separately from failures introduced by the change. Do not silence rules or add broad ESLint disables just to make a task appear clean.
|
||||
|
||||
For UI work, manually verify:
|
||||
For UI changes, manually verify at least the affected authenticated route and its corresponding public/shared route if applicable. A useful smoke-test path is:
|
||||
|
||||
1. The affected authenticated route
|
||||
2. The corresponding public/shared route when applicable
|
||||
3. Loading, error, and empty states
|
||||
4. Study resume and restart behavior when relevant
|
||||
5. Drag and drop when relevant
|
||||
6. Light and dark themes
|
||||
7. A narrow mobile viewport
|
||||
8. No horizontal overflow
|
||||
9. Keyboard access for changed controls
|
||||
1. Log in or complete first-time setup.
|
||||
2. Create or open a class.
|
||||
3. Import or edit representative flashcards and quizzes.
|
||||
4. Exercise the changed action, including loading/error/empty states.
|
||||
5. Check study resume/restart, drag/drop if relevant, and public sharing if relevant.
|
||||
6. Check light/dark themes and a narrow mobile viewport.
|
||||
|
||||
## Known repository conditions
|
||||
|
||||
- `README.md` is still mostly stock create-next-app documentation.
|
||||
- `study-app-implementation-plan.md` contains useful historical context but may be stale.
|
||||
- `task.md` records completed implementation phases.
|
||||
- `SKILL.md` describes the expected workflow for new API routes.
|
||||
- The current lint baseline includes existing errors such as `any` usage and React effect-rule violations.
|
||||
- A successful `next build` is the primary repository-wide compile and type check.
|
||||
- The current middleware convention produces a known deprecation warning.
|
||||
- `docker-compose.yml` may publish port `3000`, while the production Docker image uses port `3726`. Verify the mapping before relying on production Compose.
|
||||
- Do not silently fix known baseline issues as unrelated cleanup.
|
||||
- `README.md` is still mostly the default create-next-app README and is not a reliable description of the current product.
|
||||
- `study-app-implementation-plan.md` is a valuable architectural reference, but some details are historical and the current source code wins.
|
||||
- `task.md` records completed implementation phases and is useful for feature context.
|
||||
- `SKILL.md` is the project-specific workflow for adding API routes; follow it for new route work.
|
||||
- The current full lint command has pre-existing errors in several files, including existing `any` usage and React effect-rule violations. Do not attribute those to an unrelated small change without checking the changed lines.
|
||||
- A successful `next build` is the primary repository-wide compile/type verification currently available.
|
||||
- Next build output currently warns about the deprecated `middleware` file convention. Treat that as known baseline unless the task is specifically about the migration.
|
||||
|
||||
## Extending the application
|
||||
|
||||
For a genuinely new study mode:
|
||||
For a genuinely new study mode, follow the existing extensibility pattern:
|
||||
|
||||
1. Add or update the Prisma model and migration when persistence is required.
|
||||
1. Add the domain model and migration in Prisma if persistence is needed.
|
||||
2. Add a focused service in `src/services/`.
|
||||
3. Add API routes under `src/app/api/`.
|
||||
4. Add page routes and client components.
|
||||
5. Add the mode to `src/config/studyModes.ts` when it belongs in shared navigation.
|
||||
6. Extend progress, import, scoring, or sharing only when required.
|
||||
7. Verify authentication, public/private boundaries, mobile layout, and both themes.
|
||||
4. Add the page route and client components.
|
||||
5. Add the mode to `src/config/studyModes.ts` if it belongs in shared class navigation.
|
||||
6. Update public sharing, progress, import, or scoring only if the new mode needs those capabilities.
|
||||
7. Verify auth, mobile layout, theme support, and any public/private boundary.
|
||||
|
||||
Do not add a new mode by placing unrelated logic into `deckService.ts`, `quizService.ts`, or a large page component.
|
||||
Do not add a new mode by placing unrelated logic into `deckService.ts`, `quizService.ts`, or a giant page component. The existing separation is intentional and is the main maintainability boundary in this project.
|
||||
|
|
|
|||
23
Dockerfile
23
Dockerfile
|
|
@ -1,19 +1,11 @@
|
|||
# ---- base ----
|
||||
FROM node:22-slim AS base
|
||||
RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---- deps ----
|
||||
FROM base AS deps
|
||||
FROM node:22-slim AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# ---- production dependencies ----
|
||||
FROM deps AS runtime-deps
|
||||
RUN npm prune --omit=dev
|
||||
RUN npm install
|
||||
|
||||
# ---- builder ----
|
||||
FROM base AS builder
|
||||
FROM node:22-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
|
@ -21,13 +13,13 @@ RUN npx prisma generate
|
|||
RUN npm run build
|
||||
|
||||
# ---- runner ----
|
||||
FROM base AS runner
|
||||
FROM node:22-slim AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3726
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV DATABASE_URL="file:/app/data/study.db"
|
||||
|
||||
RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||
RUN useradd --system --create-home appuser && mkdir -p /app/data && chown -R appuser:appuser /app
|
||||
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
|
|
@ -35,13 +27,10 @@ COPY --from=builder /app/.next/static ./.next/static
|
|||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/prisma.config.ts ./
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
COPY --from=runtime-deps /app/node_modules ./node_modules
|
||||
RUN npm install prisma@^7.8.0
|
||||
COPY docker-entrypoint.sh ./
|
||||
RUN chmod +x docker-entrypoint.sh
|
||||
# Running as root to avoid permission denied on Unraid host-mounted volumes
|
||||
EXPOSE 3726
|
||||
VOLUME ["/app/data"]
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=6 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:3726/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
|
|
|
|||
81
README.md
81
README.md
|
|
@ -1,83 +1,4 @@
|
|||
Study Desk is a self-hosted, single-user study application.
|
||||
|
||||
## Local development
|
||||
|
||||
```powershell
|
||||
npm.cmd ci
|
||||
npx.cmd prisma generate
|
||||
npm.cmd run dev
|
||||
```
|
||||
|
||||
The automated test harness always creates a uniquely named database under
|
||||
`.test-databases/`, applies committed migrations, and removes the database after
|
||||
the run. It refuses `dev.db`, `/app/data/study.db`, existing databases, and paths
|
||||
that are not explicitly test-named.
|
||||
|
||||
## Production container
|
||||
|
||||
Set a unique session secret of at least 32 characters. Production startup fails
|
||||
before migrations or the HTTP server when the secret is missing, short, or the
|
||||
published development fallback.
|
||||
|
||||
```powershell
|
||||
$env:SESSION_SECRET = '<at-least-32-random-characters>'
|
||||
docker compose -f docker-compose.yml up --build
|
||||
```
|
||||
|
||||
The host listens on `http://localhost:3000`; the container listens on port 3726.
|
||||
Development Compose is intentionally explicit and is never auto-merged:
|
||||
|
||||
```powershell
|
||||
docker compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
For first-time production setup, either provide an Argon2 encoded
|
||||
`ADMIN_PASSWORD_HASH`, or temporarily set `ALLOW_INITIAL_SETUP=true` for the
|
||||
one-time setup request. Remove the flag after setup. Production HTTP password
|
||||
reset initiation is unavailable; run this on the server instead:
|
||||
|
||||
```powershell
|
||||
npm.cmd run auth:reset
|
||||
```
|
||||
|
||||
If TLS terminates at a trusted reverse proxy, forward requests only from that
|
||||
proxy and set `SECURE_COOKIES=true`. Leave it false for intentional plain HTTP;
|
||||
Secure cookies cannot be used over plain HTTP.
|
||||
|
||||
## Database backup, migration adoption, and restore
|
||||
|
||||
Never run migration tests against the only copy of a study database. Create a
|
||||
lock-safe SQLite backup through the backup API and verify it before deployment:
|
||||
|
||||
```powershell
|
||||
$env:DATABASE_URL = 'file:./data/study.db'
|
||||
npm.cmd run db:backup -- --output ./backups/study-before-upgrade.db
|
||||
npm.cmd run db:preflight
|
||||
npx.cmd prisma migrate deploy
|
||||
```
|
||||
|
||||
The material-group preflight reports one of four states:
|
||||
|
||||
- `APPLY`: migration history is complete and the group schema is absent; run
|
||||
`prisma migrate deploy` normally.
|
||||
- `ADOPT`: prior migrations are tracked and the database exactly matches the
|
||||
intended schema of one or more unrecorded committed migrations. This includes
|
||||
recovery from P3018 when an already schema-pushed table or column exists.
|
||||
After reviewing the verified backup, explicitly run
|
||||
`npm run db:preflight -- --resolve --backup <backup-path>`.
|
||||
- `CURRENT`: migration and schema already agree.
|
||||
- `CONFLICT`: partial or unknown state. Stop and recover manually; do not use
|
||||
`db push`, reset, edit applied migrations, or mark anything applied.
|
||||
|
||||
The production entrypoint runs this preflight before `prisma migrate deploy`.
|
||||
It refuses `ADOPT` and `CONFLICT` states without changing migration history;
|
||||
adoption always remains an explicit backup-gated operator action.
|
||||
|
||||
Restore drill: stop Study Desk, keep the damaged database as evidence, restore
|
||||
the verified backup to a new path, run SQLite `integrity_check` plus
|
||||
`npm run db:preflight`, start the same prior application image against the
|
||||
restored path, and compare class/deck/quiz counts plus representative content.
|
||||
Only swap the production path after those checks pass.
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
# Confirmed Findings
|
||||
|
||||
Severity: Critical / High / Medium / Low / Observation. Confidence: Confirmed / High-confidence inference / Unverified risk.
|
||||
Full structured detail for every finding (files, functions, scenario, expected/actual, root cause, impact, evidence, fix direction, complexity, regression test, runtime-confirmation flag) is in `FINDINGS.json` (validated). Source IDs from specialist auditors are in parentheses.
|
||||
|
||||
| ID | Severity | Confidence | Title | Area |
|
||||
|---|---|---|---|---|
|
||||
| FIN-01 (DBAUD-01, GRP-01) | **Critical** | Confirmed | Schema/migration drift: `MaterialGroup` table + `groupId` columns never migrated — fresh deployments' databases are incompatible with the app (verified: `no such column: Deck.groupId`, `no such table: MaterialGroup`) | DB/migrations |
|
||||
| FIN-02 (AUTH-01, OPS-03) | **Critical** | High-confidence inference | Hardcoded fallback `SESSION_SECRET` (`"dev-session-secret-change-in-production-must-be-32-chars"`) in `src/lib/auth.ts:15` + `src/proxy.ts:8-9`; empty env var in shipped compose → forgeable session cookie → full auth bypass | Auth/deploy |
|
||||
| FIN-03 (QUIZ-01, ADV-01) | **High** | Confirmed | In-viewer "Retake Missed" never sets `retakeIds` prop → persisted as a full attempt with a wrong, lower score; pollutes SEQUENTIAL progress | Quiz |
|
||||
| FIN-04 (CARD-01, IMPT-01, QUIZ-06) | **High** | Confirmed | Stale card ids in saved progress never filtered on resume (`filterAndClampOrder` dead code) → deleting a card mid-session bricks the flashcard study session | Flashcards |
|
||||
| FIN-05 (OPS-01) | **High** | Confirmed | Port mismatch: container listens on 3726, compose publishes 3000:3000 → production deployment unreachable | Deploy |
|
||||
| FIN-06 (OPS-06) | **High** | High-confidence inference | No `.dockerignore`: Windows-host builds inject win32 native modules (better-sqlite3, argon2) into the Linux image | Deploy |
|
||||
| FIN-07 (OPS-08) | **High** | Confirmed | `docker-compose.override.yml` auto-merges on plain `docker compose up` → production command silently runs dev mode | Deploy |
|
||||
| FIN-08 (AUTH-04, ADV-09) | Medium | High-confidence inference | First-login provisioning takeover; `ADMIN_PASSWORD_HASH` env is dead config | Auth |
|
||||
| FIN-09 (AUTH-02) | Medium | High-confidence inference | Proxy auth bypass for any path containing `.` (latent today; would expose any future non-UUID-id route) | Auth |
|
||||
| FIN-10 (AUTH-03) | Medium | High-confidence inference | Password-reset abuse: token overwrite DoS, spoofable `x-forwarded-for` rate limits, unthrottled `complete` | Auth |
|
||||
| FIN-11 (QUIZ-02) | Medium | High-confidence inference | Attempt route stores unvalidated `answersJson`; duplicate option ids inflate SATA credit; malformed shapes → 500 | Quiz |
|
||||
| FIN-12 (QUIZ-03, FE-04) | Medium | Confirmed | Quiz Finish double-submit creates duplicate attempts; failure path silent | Quiz/FE |
|
||||
| FIN-13 (QUIZ-04) | Medium | High-confidence inference | Progress PATCH can land after Finish's DELETE → stale "Continue" row for a completed quiz | Quiz |
|
||||
| FIN-14 (CARD-02, FE-05, QUIZ-09) | Medium | High-confidence inference | Fire-and-forget, unsequenced progress autosaves: out-of-order PATCHes regress resume state; multi-tab last-write-wins | Flashcards/Quiz |
|
||||
| FIN-15 (CARD-03, ADV-07) | Medium | Confirmed | Completed flashcard session never persisted as complete → resume re-shows the last graded card | Flashcards |
|
||||
| FIN-16 (CARD-04) | Medium | Confirmed | SRS review endpoint enforces membership only — no due-date or `newCardsPerDay` enforcement server-side | SRS |
|
||||
| FIN-17 (CARD-05, ADV-02) | Medium | Confirmed | Unbounded DB scans: whole `StudyActivity` table per fetch; all SRS card states per set; Navbar polls every 60 s | Perf/DB |
|
||||
| FIN-18 (FE-01, GRP-03, FE-02, GRP-05) | Medium | Confirmed | Library optimistic mutations are fire-and-forget (no `res.ok`, no rollback); cross-group reorder omits origin group from payload | FE/Groups |
|
||||
| FIN-19 (GRP-02, CARD-07, AUTH-06) | Medium | Confirmed | Reorder/create endpoints trust client `groupId`/`sortOrder` — no class/type/existence validation; cross-class group membership can leak content into another class's share link | Groups |
|
||||
| FIN-20 (FE-03) | Medium | Confirmed | Shared quiz session resets and options reshuffle on any parent re-render (unstable inline `quiz` prop) | FE |
|
||||
| FIN-21 (IMPT-02, FE-08, QUIZ-05, ADV-10) | Medium | Confirmed | `/api/progress` PATCH completely unvalidated; unguarded client `JSON.parse` of persisted progress crashes pages | Quiz/FE |
|
||||
| FIN-22 (OPS-02) | Medium | High-confidence inference | Entrypoint migrate-deploy chain fragile: crash loop on conflicting DB state; runner prisma CLI install unverified/unpinned | Deploy |
|
||||
| FIN-23 (OPS-05) | Medium | Confirmed | Non-reproducible installs: `npm install` (not `ci`); unpinned `prisma@^7.8.0` re-install re-resolves the whole tree in the runner | Deploy |
|
||||
| FIN-24 (OPS-07) | Medium | Confirmed | No healthcheck, no backup, no documented recovery | Deploy |
|
||||
| FIN-25 (OPS-10, TEST-10) | Medium | Confirmed | CI builds/pushes without tests, lint, prisma validate, or container smoke test — deployment-breaking defects ship green | CI |
|
||||
| FIN-26 (QUIZ-07) | Low | Confirmed | SATA scoring divides by `correctIds.length` with no zero-guard → NaN | Quiz |
|
||||
| FIN-27 (QUIZ-08) | Low | Confirmed | Historical review/category breakdown recompute scores from current content, not stored attempt | Quiz |
|
||||
| FIN-28 (IMPT-03) | Low | Confirmed | Card add/edit endpoints bypass Zod: empty strings storable; CreateTab silently drops incomplete cards | Imports |
|
||||
| FIN-29 (IMPT-04) | Low | Confirmed | No payload size/string-length/array-length caps on import schemas/routes; whitespace-only names accepted | Imports |
|
||||
| FIN-30 (IMPT-05) | Low | Confirmed | SATA constraint mismatch: instructions say ≥2 correct, schema enforces ≥1 | Imports |
|
||||
| FIN-31 (IMPT-06) | Low | Confirmed | Unvalidated `name`/`groupId` override fields on import POST routes → 500; PATCH routes mask failures as 404 | Imports |
|
||||
| FIN-32 (GRP-04) | Low | Confirmed | Group deletion leaves duplicate sortOrder values in Uncategorized; no unique constraint/tie-break | Groups |
|
||||
| FIN-33 (GRP-06) | Low | Confirmed | Keyboard users cannot move items between groups (empty `handleDragOver`) | Groups/a11y |
|
||||
| FIN-34 (CARD-08) | Low | High-confidence inference | Restart deletes progress fire-and-forget; slow DELETE can remove the fresh session's progress row | Flashcards |
|
||||
| FIN-35 (CARD-09) | Low | Confirmed | Concurrent first review of same new card → P2002 → generic 500 instead of 409 | SRS |
|
||||
| FIN-36 (CARD-10) | Low | High-confidence inference | "Previous card" during the 350 ms grade animation races the pending timeout | Flashcards |
|
||||
| FIN-37 (CARD-11) | Low | Confirmed | SRS set page never refreshes on focus → stale membership after deck deletion in another tab | SRS/FE |
|
||||
| FIN-38 (CARD-06) | Low | Confirmed | Day boundary hardcoded to Arizona (UTC-7): "today"/new-card limit/streak roll over at 07:00 UTC | SRS |
|
||||
| FIN-39 (AUTH-05) | Low | Confirmed | Proxy `destroy()` cookie-clear lost on redirect; layout check ignores `sessionGeneration` | Auth |
|
||||
| FIN-40 (AUTH-08) | Low | Confirmed | Session cookie Secure flag off in shipped compose (plain HTTP, no TLS story) | Auth/deploy |
|
||||
| FIN-41 (DBAUD-02, AUTH-07) | Low | Confirmed | `/api/share` accepts arbitrary `targetType` → repeatable junk all-NULL `ShareLink` rows | Auth/DB |
|
||||
| FIN-42 (DBAUD-03) | Low | High-confidence inference | `dev.db` committed in git history (8 commits), remains in blobs on the LAN remote | Repo hygiene |
|
||||
| FIN-43 (OPS-09) | Low | Confirmed | `.gitignore` gaps (`data/`, `study.db*`); scratch files `out.css`/`temp.css`/`test.css` tracked | Repo hygiene |
|
||||
| FIN-44 (FE-07, ADV-04) | Low | Confirmed | Dashboard `fetchClasses` no `.catch`/`res.ok` → unhandled rejection, misleading empty state (or render crash) | FE |
|
||||
| FIN-45 (FE-09) | Low | Confirmed | Effect fetches without `.catch` in GenerateTab and ShareMenu | FE |
|
||||
| FIN-46 (FE-10) | Low | High-confidence inference | Cross-class navigation fetch race renders wrong-class data; module cache never invalidated | FE |
|
||||
| FIN-47 (ADV-03) | Low | Confirmed | Logout has no error handling — failed logout strands the user | FE |
|
||||
| FIN-48 (ADV-05) | Low | Confirmed | `slugify` can yield an empty slug (unreachable class); renames never update the URL slug | Classes |
|
||||
| FIN-49 (ADV-08) | Low | High-confidence inference | Shared-viewer localStorage keys item-scoped, not token-scoped → session bleed across tokens | Sharing/FE |
|
||||
| FIN-50 (GRP-07) | Observation | Confirmed | Group `sortOrder` uses inverted desc convention with unvalidated PATCH — latent trap | Groups |
|
||||
| FIN-51 (FE-11) | Observation | Confirmed | Collapsed-groups state read in effect → one-frame expand flash | FE |
|
||||
| FIN-52 (AUTH-10) | Observation | Confirmed | Shared quiz links ship the full answer key to anonymous viewers — by design, worth a warning | Sharing |
|
||||
| FIN-53 (TEST-01) | Low | Confirmed | False-confidence test: "distinct valid state for every rating" never asserts distinctness | Tests |
|
||||
|
||||
## Notes on consolidation
|
||||
|
||||
- Duplicate findings across specialist reports were merged under one root cause (see `sourceIds`).
|
||||
- FE-06 ("all /api/* routes unauthenticated") was **refuted** by the adversarial review — `src/proxy.ts` IS the compiled middleware and enforces session checks; the residual truth is FIN-09 (dot-bypass). See REJECTED_FINDINGS.md.
|
||||
- OPS-02's fresh-volume crash-loop framing was weakened by the adversarial review (on a fresh volume `migrate deploy` succeeds; the app then fails per FIN-01) — reframed as FIN-22.
|
||||
- FIN-18 severity downgraded from the frontend auditor's High to Medium after adversarial review (no visible break for a single user; silent divergence self-heals on reload).
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
# Coverage Map
|
||||
|
||||
Mapping of repository areas → audit status → specialist report reference. Statuses: done / done+verified (parent-verified with runtime evidence) / partial.
|
||||
|
||||
| Area | Files | Auditor | Status | Report ref |
|
||||
|---|---|---|---|---|
|
||||
| Prisma schema/migrations/transactions/cascades | prisma/schema.prisma, prisma/migrations/*, prisma.config.ts, src/lib/db.ts, services | DB specialist (task-1) | done+verified (drift reproduced) | sa_20260806_054030_000000000_5ed877563a0d |
|
||||
| Quiz scoring/attempts/history/retakes/progress | src/lib/scoring.ts, quizService, progressService, quizzes/*, api/quizzes/**, api/progress | Quiz specialist (task-2) | done+verified (server scoring path re-checked) | sa_20260806_054030_000000000_e75a8ce575f9 |
|
||||
| Flashcards/SRS/progress/deletion/ordering | flashcards/*, spaced-repetition/*, cardService, deckService, spacedRepetitionService, api/cards|decks|spaced-repetition-sets|progress | Flashcard specialist (task-3) | done | sa_20260806_054030_000000000_a222804c133c |
|
||||
| Auth/protected routes/API authz/sharing | src/proxy.ts, lib/auth.ts, authService, shareService, shareMetadata, api/auth/**, api/share, shared/*, all api routes | Auth specialist (task-4) | done+verified (proxy + fallback secret read directly) | sa_20260806_054030_000000000_aab0c59dbad3 |
|
||||
| Imports/exports/generation/Zod/malformed input | lib/validation/*, jsonRepair, components/import/*, api/decks|quizzes|material-groups|settings | Import specialist (task-5) | done | sa_20260806_054030_000000000_5e72c2713989 |
|
||||
| Material groups/library/drag-drop/deletion/orphans | api/material-groups/**, reorder routes, library pages, classService | Groups specialist (task-6) | done | sa_20260806_054030_000000000_9ae1f3dfa282 |
|
||||
| Frontend state/refresh/races/localStorage/boundaries | all components + pages + shared viewers | Frontend specialist (task-7) | done | sa_20260806_054030_000000000_0e17fd155702 |
|
||||
| Test quality/coverage gaps | *.test.ts, vitest.config.ts, package.json, CI workflow | Test specialist (task-8) | done | sa_20260806_054030_000000000_cd3652deb3f7 |
|
||||
| Docker/startup/env/scripts/production/recovery | Dockerfile, docker-entrypoint.sh, compose files, package.json, prisma.config.ts, next.config.ts, .gitignore, CI | Ops specialist (task-9) | done | sa_20260806_054030_000000000_f49cdaacaabf |
|
||||
| Adversarial review (challenge Critical/High + missed issues) | (all of the above + lightly-covered files) | Adversarial specialist (task-10) | done — 9/10 challenged findings confirmed/weakened, FE-06 refuted, 10 new issues added | sa_20260806_055608_000000000_c550c742d5dc |
|
||||
|
||||
## Parent-level verification performed (see VERIFICATION_LOG.md and audit-results/tmp/)
|
||||
|
||||
- `npx prisma validate` — PASS
|
||||
- `npm test` — PASS (30 tests: 20 arcade + 10 spacedRepetition)
|
||||
- `npm run lint` — 28 baseline problems (9 errors / 19 warnings), all pre-existing
|
||||
- `npm run build` — PASS (route table incl. "ƒ Proxy (Middleware)")
|
||||
- `prisma migrate deploy` on fresh temp DB — PASS (creates DB **missing** MaterialGroup/groupId → FIN-01 drift confirmed)
|
||||
- Temp DB introspection (`audit-results/tmp/inspect-db.cjs`) — `MaterialGroup` table and `groupId` columns absent
|
||||
- Prisma-shaped SQL against temp DB (`audit-results/tmp/reproduce-drift.cjs`) — `no such column: Deck.groupId`, `no such table: MaterialGroup`, `no such column: ShareLink.groupId`
|
||||
- `FINDINGS.json` validity — PASS (53 findings; validator `audit-results/tmp/validate-findings.cjs`)
|
||||
|
||||
## Coverage gaps (areas NOT fully audited)
|
||||
|
||||
1. **Arcade feature internals** — intentionally out of scope (may be removed). Only checked for shared-DB/build/deploy impact (Arcade models/indexes verified in migrations).
|
||||
2. **Real browser interaction** — no browser available in the audit environment: drag-drop, resume flows, share pages, keyboard a11y, theme behavior were code-verified only; findings needing runtime confirmation are flagged in UNVERIFIED_RISKS.md.
|
||||
3. **Container build/run** — docker CLI unavailable: OPS-02/FIN-22, FIN-06, FIN-24 need a container run to observe exact failure modes.
|
||||
4. **git history deep-dive** — limited to drift commit (7af0935), dev.db commits, and migration commits; other commits not diffed line-by-line.
|
||||
5. **Committed dev.db blob contents** — could not be fully enumerated (no sqlite3 CLI; blob grep only; no admin_password hash found in checked blobs).
|
||||
6. **Live dev.db** — none exists in the workspace; runtime behavior on a populated database (perf findings FIN-17) not measured.
|
||||
7. **Network/remote behavior** — the Forgejo remote and CI execution were not reachable; CI findings are static analysis of `.forgejo/workflows/build.yml`.
|
||||
8. **External services** — none (app calls no LLM/third-party APIs).
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
# Study Desk — Overnight Read-Only Audit: Executive Summary
|
||||
|
||||
Status: **COMPLETE** (2026-08-06)
|
||||
|
||||
## Scope and method
|
||||
|
||||
Comprehensive read-only audit of the Study repository (Next.js 16 / React 19 / Prisma 7 / SQLite, self-hosted single-user study app). Ten specialist subagents audited: (1) Prisma schema/migrations/transactions/cascades/drift, (2) quiz scoring/attempts/retakes/progress, (3) flashcards/SRS/progress/ordering, (4) auth/protected routes/API authorization/sharing, (5) imports/exports/generation/Zod validation, (6) material groups/library/drag-drop/deletion, (7) frontend state/races/localStorage/boundaries, (8) test quality, (9) Docker/env/production/recovery, (10) adversarial review challenging every Critical/High finding and hunting missed issues. The parent agent independently re-verified all Critical/High claims (code tracing, git history, temp-DB migration chain, DB introspection, Prisma-shaped SQL probes). The Arcade feature set was excluded per scope unless it affects the main app, shared dependencies, database integrity, build, or deployment.
|
||||
|
||||
## Constraints honored
|
||||
|
||||
- **No source code, tests, migrations, config, package files, lockfiles, docs, or databases modified.** Final `git status --short`: only untracked `.reasonix/` and `audit-results/`; `git diff --check` clean; `src/generated/prisma/` byte-identical before/after all prisma commands (sha256).
|
||||
- All reports and diagnostic artifacts live under `audit-results/` (reports + `tmp/`: `audit-migration.db`, `inspect-db.cjs`, `reproduce-drift.cjs`, `validate-findings.cjs`, `generated-before.sha256`).
|
||||
- Existing databases: none found in the workspace; the migration-chain test used a brand-new temp DB.
|
||||
|
||||
## Baseline verification
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `npm test` | ✅ PASS — 30 tests / 6 files (20 arcade out of scope, 10 spacedRepetition) |
|
||||
| `npm run lint` | ⚠️ 28 problems (9 errors, 19 warnings) — all pre-existing baseline |
|
||||
| `npm run build` | ✅ PASS — full route table incl. "ƒ Proxy (Middleware)" |
|
||||
| `npx prisma validate` | ✅ PASS |
|
||||
| Migration chain on fresh temp DB | ✅ PASS — 4/4 migrations apply, but the resulting DB is **missing** `MaterialGroup` + `groupId` columns (drift, see below) |
|
||||
|
||||
## Findings (53 total: 2 Critical, 5 High, 18 Medium, 25 Low, 3 Observation)
|
||||
|
||||
Full detail in `FINDINGS.json` (validated) and `CONFIRMED_FINDINGS.md`.
|
||||
|
||||
### Critical
|
||||
|
||||
1. **FIN-01 — Schema/migration drift (Confirmed, runtime-verified).** `prisma/schema.prisma` defines `MaterialGroup` and `Deck/QuizSet/ShareLink.groupId` (added by commit 7af0935, which also rewrote a committed `dev.db` via `prisma db push`) but **no migration creates them**. Every DB built by `prisma migrate deploy` — the `predev` hook and the Docker entrypoint — lacks these objects; the generated client then fails every Deck/QuizSet/ShareLink/MaterialGroup query (`no such column: Deck.groupId`, `no such table: MaterialGroup` — reproduced against a temp DB). **Any fresh install/deploy is non-functional.** The app only works on the developer's db-push-synced local database.
|
||||
2. **FIN-02 — Hardcoded session-secret fallback (High-confidence inference).** `src/lib/auth.ts:15` and `src/proxy.ts:8-9` fall back to the public constant `"dev-session-secret-change-in-production-must-be-32-chars"` whenever `SESSION_SECRET` is unset — and the shipped `docker-compose.yml` passes `${SESSION_SECRET}`, which is empty by default. Anyone with the source can forge an iron-session cookie (`{isAuthenticated:true, sessionGeneration:1}`) and fully bypass authentication on such deployments.
|
||||
|
||||
### High
|
||||
|
||||
3. **FIN-03 — "Retake Missed" persists as a full attempt with a wrong lower score** (in-viewer retake never sets the `retakeIds` prop; server scores all unanswered questions as 0) and pollutes SEQUENTIAL progress.
|
||||
4. **FIN-04 — Deleting a card mid-session bricks the flashcard study session** (`filterAndClampOrder` is dead code; resume never filters stale ids; no skip UI).
|
||||
5. **FIN-05 — Production compose is unreachable**: container listens on 3726 (`ENV PORT=3726`), compose publishes 3000:3000.
|
||||
6. **FIN-06 — No `.dockerignore`**: Windows-host builds inject win32 native modules (better-sqlite3, argon2) into the Linux image.
|
||||
7. **FIN-07 — `docker-compose.override.yml` auto-merges**: plain `docker compose up` silently runs dev mode in production.
|
||||
|
||||
### Medium (selected)
|
||||
|
||||
First-login provisioning takeover + dead `ADMIN_PASSWORD_HASH` (FIN-08); proxy auth bypass for dot-containing paths (FIN-09); password-reset abuse (FIN-10); unvalidated attempt `answersJson` (FIN-11); finish double-submit duplicates attempts (FIN-12); progress PATCH/DELETE race (FIN-13); unsequenced progress autosaves (FIN-14); completed session resumes on last card (FIN-15); SRS review endpoint lacks due/new-card-limit enforcement (FIN-16); unbounded DB scans (FIN-17); library optimistic mutations without rollback (FIN-18); unvalidated `groupId`/`sortOrder` incl. cross-class group membership (FIN-19); shared-quiz reshuffle on re-render (FIN-20); unvalidated `/api/progress` + unguarded `JSON.parse` crashes (FIN-21); entrypoint migrate fragility (FIN-22); non-reproducible installs (FIN-23); no healthcheck/backup (FIN-24); CI ships without tests/lint/prisma/smoke gates (FIN-25).
|
||||
|
||||
### Low / Observation
|
||||
|
||||
25 Low (e.g. SATA div-by-zero NaN, historical score recompute, card-edit endpoints without Zod, no import size caps, sortOrder duplicates after group deletion, keyboard cross-group moves impossible, restart DELETE race, P2002 → 500, git history containing `dev.db`, `.gitignore` gaps, dashboard fetch crash, empty slug from `slugify`, shared-viewer localStorage key collisions) and 3 Observations (group sortOrder desc convention, collapse flash, shared quizzes ship the answer key by design).
|
||||
|
||||
## Verification highlights
|
||||
|
||||
- FIN-01 disproved attempts: checked whether the client avoids selecting `groupId` (it doesn't — generated `Deck.ts` includes it in every payload), whether pages swallow errors (they don't), and whether any migration creates the objects (grep = 0 hits). Confirmed via live probes instead: see `audit-results/tmp/reproduce-drift.cjs` output in VERIFICATION_LOG.md.
|
||||
- Adversarial review **refuted** the frontend auditor's "no auth on API routes" claim (proxy is compiled middleware — verified in `.next` artifacts), **weakened** the entrypoint crash-loop framing (fresh-volume `migrate deploy` succeeds), and downgraded the optimistic-reorder severity. It independently confirmed all other Critical/High findings and added 10 missed issues, merged into the final list.
|
||||
|
||||
## Repository areas NOT fully audited
|
||||
|
||||
1. Arcade feature internals (out of scope; shared-DB/build/deploy impact only — verified in migrations).
|
||||
2. Real browser interaction (no browser available): drag-drop, resume flows, share pages, keyboard a11y, themes — code-verified; flagged for runtime confirmation in UNVERIFIED_RISKS.md.
|
||||
3. Container build/run (docker CLI unavailable): FIN-06, FIN-22, FIN-24 need a container run.
|
||||
4. Deep git-history diff of every commit; full content audit of the committed `dev.db` blobs (no sqlite3 CLI; no credentials found in the checked blobs).
|
||||
5. Runtime performance on a populated database (FIN-17 not measured).
|
||||
6. CI/remote execution (Forgejo pipeline analyzed statically).
|
||||
|
||||
## Recommended priority (when fixes are authorized)
|
||||
|
||||
1. **FIN-01** — generate and commit the missing migration (`prisma migrate dev --name add_material_groups`); add a CI drift gate. Everything else depends on a working fresh deployment.
|
||||
2. **FIN-02 / FIN-05 / FIN-06 / FIN-07** — harden the deployment path: require `SESSION_SECRET`, fix the port mapping, add `.dockerignore`, rename the dev override, and smoke-test the image in CI.
|
||||
3. **FIN-03 / FIN-04** — the two most user-visible application bugs (wrong retake scores; stuck flashcard sessions).
|
||||
4. Then the Medium cluster (validation, races, SRS enforcement, progress handling) and finally Low items + TEST_GAPS.md.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,15 +0,0 @@
|
|||
# Rejected Findings
|
||||
|
||||
Findings that were investigated and disproven, or removed as duplicate/stylistic/insignificant, with the reason.
|
||||
|
||||
| ID | Original claim | Rejection reason |
|
||||
|---|---|---|
|
||||
| FE-06 | "All `/api/*` routes are unauthenticated (no middleware, no per-route checks) — curl without a session cookie returns data" | **Refuted by adversarial review + parent verification.** `src/proxy.ts` IS the compiled Next.js 16 middleware (verified in `.next/server/functions-config-manifest.json`, `middleware.js`, and the compiled chunk; build output shows "ƒ Proxy (Middleware)"). It decrypts the iron-session cookie and enforces `isAuthenticated` + `sessionGeneration` on every path except `/login`, `/api/auth`, `/shared`, `/_next*`, `/favicon*`, and paths containing `.`. The auditor searched for `middleware.ts` (the Next 15 name) and missed `proxy.ts`. The residual truth is the dot-bypass (FIN-09). The claim that "no frontend code handles 401s because there is no auth" is also wrong — there is no 401 because the proxy redirects to /login. |
|
||||
| QUIZ-06 (standalone) | "Quiz resume with stale question ids crashes the viewer" | Merged into FIN-04. The quiz-side path is currently unreachable (no question editing/deletion exists in the app; quiz deletion cascades progress), so it is a latent variant of the same root cause (dead `filterAndClampOrder`), not a separate live defect. |
|
||||
| OPS-02 (original framing) | "Fresh-volume deployment crash-loops because `migrate deploy` fails" | **Weakened by adversarial review.** On a fresh volume `prisma migrate deploy` *succeeds* (verified against a temp DB); the fresh-volume failure is FIN-01 (schema drift → app 500s while the container runs). The crash loop requires a secondary trigger (db-push DB, tampering, lock). Reframed as FIN-22 with the crash-loop path demoted. |
|
||||
| FE-01 (original severity) | Optimistic drag-drop reorder without rollback rated High | **Severity downgraded to Medium by adversarial review.** Mechanics confirmed (fire-and-forget fetch, in-place mutation, origin group omitted from cross-group payload) but impact analysis: sortOrder gaps only, silent divergence self-heals on reload, no visible break for a single user. Merged into FIN-18. |
|
||||
| AUTH-06 (standalone) | "Cross-class group membership via reorder API exposes another class's content" | Merged into FIN-19 (same root cause: `groupId`/`sortOrder` never validated server-side). The sharing-exposure angle is recorded as impact of FIN-19. |
|
||||
| DBAUD-04 | "Relative SQLite path resolution may cause dev dual-DB split-brain" | **Disproven.** CLI (`prisma.config.ts`) and app adapter both resolve `file:./dev.db` cwd-relative; `predev` and the app run from the project root → same file. Docker uses absolute `file:/app/data/study.db`. Recorded as verified-safe in UNVERIFIED_RISKS.md. |
|
||||
| DBAUD-05/06/07, QUIZ-08-verdicts, GRP-01-verdicts (various) | "Cascades broken / transactions partial / StudyProgress duplicates via NULL tricks / id regeneration on card edit / XSS via markdown / client-server boundary violations" | **Disproven** by the respective auditors with evidence: FKs enforced by compile default; nested creates implicit-transactional; service-level guards block duplicates; PATCH edits in place (ids preserved); no `rehype-raw`; no server-module imports in client components. Recorded as verified-safe. |
|
||||
| FE-11 (as bug) | "Collapsed-groups expand flash" | Retained only as Observation (FIN-51) — cosmetic, no data impact. |
|
||||
| Various | Style preferences, harmless duplication, generic best practices, speculative micro-optimizations, Arcade-only issues | Excluded per audit scope rules. |
|
||||
|
|
@ -1,340 +0,0 @@
|
|||
# Study Desk audit evaluation and remediation plan
|
||||
|
||||
Date: 2026-08-06
|
||||
Scope: independent evaluation of `CONFIRMED_FINDINGS.md`, `FINDINGS.json`, the supporting audit reports, and the current repository source.
|
||||
Change policy for this evaluation: this plan is the only new file; no application source, tests, migrations, configuration, generated files, database, or existing audit artifact was changed.
|
||||
|
||||
## Executive verdict
|
||||
|
||||
The audit is credible and the central findings hold up against the current source. The most urgent problems are not cosmetic:
|
||||
|
||||
1. The committed migration chain does not create `MaterialGroup` or the three `groupId` columns, while the generated Prisma client selects those fields. A database created only with `prisma migrate deploy` is incompatible with the app.
|
||||
2. Production authentication can use a public fallback session secret, making a deployment with an empty `SESSION_SECRET` forgeable.
|
||||
3. The in-viewer quiz retake flow loses the retake scope and records the retake as a full, incorrectly scored attempt.
|
||||
4. Flashcard resume trusts stale IDs and unguarded JSON, so deletion or malformed progress can leave the study viewer blank or crash it.
|
||||
5. The production container path is presently unreliable: the port mapping is wrong without the development override, the conventional override silently changes normal Compose behavior, the build context can include host `node_modules`, and the runner installs a ranged Prisma CLI outside the lockfile.
|
||||
|
||||
I agree with most of the remaining findings as defects or resilience gaps, but not always with their severity or proposed remedy. In particular:
|
||||
|
||||
- FIN-04 needs more than wiring in the existing `filterAndClampOrder`: that helper does not preserve the logical current card when a stale ID occurs before the saved index, and it turns a completed index back into the last card.
|
||||
- FIN-05 should normally use `3000:3726`, preserving the documented host port while mapping it to the actual container port.
|
||||
- FIN-22 should not be fixed with an automatic migration retry loop. Schema conflicts should fail fast with actionable diagnostics and a documented recovery path.
|
||||
- FIN-27 is more important than Low because changing quiz content can rewrite the meaning of persisted history.
|
||||
- FIN-34 is only partially present: the dedicated deck page already awaits both DELETE requests, but the library start/restart path fires them without waiting.
|
||||
- FIN-38 is not currently a defect for this Arizona-based installation. It becomes a portability requirement only if the app is meant to support a configurable local day boundary.
|
||||
- FIN-42 does not justify a destructive history rewrite without evidence that the old database contained sensitive data and without coordinating every clone/remote.
|
||||
- FIN-50 is an intentional convention. Newest named groups must remain first and Uncategorized must remain last; validation should make that convention harder to corrupt.
|
||||
- FIN-52 is an architectural consequence of local grading in an anonymous read-only viewer. It merits a sharing warning, not removal of the answer key unless the product adopts server-side anonymous grading.
|
||||
|
||||
## Finding-by-finding disposition
|
||||
|
||||
Priority definitions: P0 = release/deployment blocker; P1 = next remediation batch; P2 = important hardening; P3 = worthwhile backlog; Conditional = verify a stated condition before changing behavior; No separate fix = intentional behavior or covered by another item.
|
||||
|
||||
| ID | Verdict | Worth fixing? | Priority and disposition |
|
||||
|---|---|---:|---|
|
||||
| FIN-01 | Agree | Yes | **P0.** Add the missing migration plus a safe adoption runbook for already-`db push`-synchronized databases. |
|
||||
| FIN-02 | Agree, conditional on an unset/blank production secret | Yes | **P0.** Production must fail before serving requests; development may use an explicitly development-only value. |
|
||||
| FIN-03 | Agree | Yes | **P1.** Make attempted-question scope explicit and derive partial-retake status on the server. |
|
||||
| FIN-04 | Agree; proposed helper is insufficient as written | Yes | **P1.** Normalize order, index, and results while preserving the logical current card and completed state. |
|
||||
| FIN-05 | Agree | Yes | **P0.** Map host 3000 to container 3726 and smoke-test it. |
|
||||
| FIN-06 | Agree with the mechanics; exact runtime failure is unconfirmed | Yes | **P0.** Add `.dockerignore` and prove native modules load in the Linux image. |
|
||||
| FIN-07 | Agree | Yes | **P0.** Rename the development override and document explicit dev/prod commands. |
|
||||
| FIN-08 | Partly agree: first-login setup is intentional, accidental exposure and dead config are real | Yes | **P1.** Honor a pre-provisioned admin hash or require an explicit one-time setup mode. |
|
||||
| FIN-09 | Agree; latent with current IDs | Yes | **P1.** Replace the broad dot bypass with exact public/static path rules. |
|
||||
| FIN-10 | Agree | Yes | **P1.** Prevent token overwrite, remove spoofable per-IP trust, and make reset initiation a local/admin operation. |
|
||||
| FIN-11 | Agree | Yes | **P1.** Validate the whole attempt envelope, question scope, option ownership, array uniqueness, and types. |
|
||||
| FIN-12 | Agree | Yes | **P1.** Add an in-flight guard, disable Finish, and surface failure/retry state. |
|
||||
| FIN-13 | Agree as a credible timing race | Yes | **P1.** Persist the attempt and clear full-attempt progress in one server transaction. |
|
||||
| FIN-14 | Agree as a credible timing race | Yes | **P2.** Add session identity and monotonic revisions, not only client debouncing. |
|
||||
| FIN-15 | Agree | Yes | **P1.** Persist `currentIndex === order.length` and restore the summary state. |
|
||||
| FIN-16 | Agree; impact is lower in a protected single-user app | Yes | **P2.** Accept only the queue-eligible card, including the intended learn-ahead rule. |
|
||||
| FIN-17 | Agree that the queries are unbounded; user impact is unmeasured | Conditional | **P3.** Benchmark realistic data first, then optimize queries that exceed the budget. |
|
||||
| FIN-18 | Agree | Yes | **P2.** Stop mutating shared objects, capture the origin group before mutation, await writes, and rollback/refetch on error. |
|
||||
| FIN-19 | Agree | Yes | **P1.** Enforce class/type/group membership and server-owned ordering; retain a share-page defense. |
|
||||
| FIN-20 | Agree | Yes | **P1.** Give the viewer a stable session input so unrelated parent renders cannot reset it. |
|
||||
| FIN-21 | Agree | Yes | **P1.** Validate progress writes and defensively normalize legacy/corrupt progress on reads. |
|
||||
| FIN-22 | Partly agree with the operational gap, not the retry remedy | No separate fix | Fail fast; cover CLI pinning in FIN-23 and recovery/health in FIN-24. Do not retry a conflicting migration automatically. |
|
||||
| FIN-23 | Agree | Yes | **P0.** Use `npm ci` and ship a lockfile-pinned Prisma CLI/runtime path. |
|
||||
| FIN-24 | Agree | Yes | **P1.** Add a schema-aware health check and tested backup/restore instructions. |
|
||||
| FIN-25 | Agree | Yes | **P0/P1.** Gate test, build, Prisma validation/drift, and container smoke. Stage lint until its known baseline is resolved. |
|
||||
| FIN-26 | Agree | Yes | **P1.** Return zero for an invalid zero-correct SATA question and test finite totals. |
|
||||
| FIN-27 | Agree; severity should be Medium | Yes | **P2.** Persist an immutable result/review snapshot for new attempts, with legacy fallback. |
|
||||
| FIN-28 | Agree | Yes | **P2.** Reuse a trimmed card-content schema and reject rather than silently drop incomplete Create-tab rows. |
|
||||
| FIN-29 | Agree | Yes | **P2.** Add reasonable string, array, and request-size limits with clear 400/413 errors. |
|
||||
| FIN-30 | Agree that the contract is inconsistent | Yes | **P2.** Make SATA imports require at least two correct options, matching the shipped generation instructions; preserve legacy stored data. |
|
||||
| FIN-31 | Agree | Yes | **P2.** Validate complete request envelopes and map only actual not-found errors to 404. |
|
||||
| FIN-32 | Agree | Yes | **P2.** Delete/reassign/renumber in one transaction and add deterministic item tie-breaks. |
|
||||
| FIN-33 | Agree | Yes | **P2.** Add an explicit keyboard-accessible “Move to group” action; do not depend on drag gestures. |
|
||||
| FIN-34 | Partly agree | Yes | **P2.** Fix the library path that does not await deletion; make deletion session-aware with FIN-14. |
|
||||
| FIN-35 | Agree | Yes | **P3.** Translate concurrent first-review uniqueness conflicts to 409 and refetch. |
|
||||
| FIN-36 | Agree as a credible timing race | Yes | **P2.** Disable navigation while grading and cancel/ignore stale animation callbacks. |
|
||||
| FIN-37 | Agree | Yes | **P3.** Refresh set/deck membership on focus and after local deck-change events. |
|
||||
| FIN-38 | Factually correct, but Arizona is the current intended boundary | Not now | Keep the existing behavior. Revisit only with an explicit portability requirement and a timezone setting/migration plan. |
|
||||
| FIN-39 | Agree about the lost clear-cookie response; layout impact is overstated because proxy checks generation | Yes | **P2.** Destroy the cookie on the actual redirect response and test its header. |
|
||||
| FIN-40 | Deployment-policy gap, not a bug on intentional HTTP | Yes, as documentation/config | **P1.** Document a TLS proxy profile and `SECURE_COOKIES=true`; do not enable Secure cookies on plain HTTP. |
|
||||
| FIN-41 | Agree | Yes | **P1.** Validate a strict target enum and target existence; reject impossible/null-target rows. |
|
||||
| FIN-42 | Agree that blobs exist; sensitivity is unproven | Conditional | Do not rewrite history now. Inventory the old DB offline before any public remote; rewrite only with explicit coordination if sensitive data is found. |
|
||||
| FIN-43 | Partly agree | Yes | **P3.** Ignore `data/` and production DB sidecars. Confirm ownership, then remove unreferenced CSS scratch files. Do not automatically ignore the audit plan. |
|
||||
| FIN-44 | Agree | Yes | **P2.** Add `res.ok`, error, and retry handling; never present a failed load as an empty dashboard. |
|
||||
| FIN-45 | Agree | Yes | **P3.** Add abort/error handling to settings/share fetches. |
|
||||
| FIN-46 | Agree as a credible timing race | Yes | **P2.** Use `AbortController` or a request generation tied to `classSlug`; remove or bound the module cache. |
|
||||
| FIN-47 | Agree | Yes | **P3.** Show logout failure and retry; do not falsely claim logout if the server call failed. |
|
||||
| FIN-48 | Partly agree | Yes | **P2.** Guarantee a non-empty unique slug. Keep slugs stable on rename unless redirect/history semantics are deliberately designed. |
|
||||
| FIN-49 | Agree; impact is same-browser only | Yes | **P3.** Include the share token in local session keys and migrate/ignore old keys safely. |
|
||||
| FIN-50 | Intentional newest-first convention | No separate fix | Preserve descending named-group order and final Uncategorized placement; restrict arbitrary sort-order writes under FIN-19/31. |
|
||||
| FIN-51 | Agree, cosmetic only | Not now | Defer unless it can be removed without hydration mismatch or new effect-rule violations. |
|
||||
| FIN-52 | Expected local-grading design | Warning only | **P3.** Explain in ShareMenu that recipients can inspect correct answers; do not imply answer secrecy. |
|
||||
| FIN-53 | Agree | Yes | **P1.** Replace the false-confidence assertion and add scoring/scheduling edge cases. |
|
||||
|
||||
## Remediation sequence
|
||||
|
||||
### Phase 0 — safeguards and test foundation
|
||||
|
||||
Do this before behavior or schema changes.
|
||||
|
||||
1. Create a database-backed Vitest harness that always points Prisma at a unique disposable SQLite path. It must refuse to run if the resolved path is `dev.db`, `/app/data/study.db`, or any existing non-test database.
|
||||
2. Add helpers to apply committed migrations to the disposable database and dispose of it after the test process.
|
||||
3. Record the current lint baseline separately. Do not make a failing baseline a nominally “green” CI gate, and do not mix unrelated lint cleanup into P0 fixes.
|
||||
4. Add focused pure tests first for scoring, progress normalization, import schemas, and rate limiting. These give fast feedback before route/service integration tests.
|
||||
5. Before testing any real deployment database, make and verify a restorable backup. No remediation command should run against the only copy.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Tests demonstrably create and use only a disposable DB.
|
||||
- A deliberate attempt to point the harness at the real DB aborts.
|
||||
- Existing `npm.cmd test` remains green.
|
||||
|
||||
### Phase 1 — restore a safe, reproducible deployment path
|
||||
|
||||
Addresses FIN-01, FIN-02, FIN-05–10, FIN-23–25, FIN-39, and FIN-40.
|
||||
|
||||
#### 1A. Repair migration drift without breaking already-pushed databases
|
||||
|
||||
1. Generate a new migration; never edit the four applied migrations. It must add:
|
||||
- `MaterialGroup` with its Class cascade foreign key.
|
||||
- nullable `groupId` on Deck and QuizSet with `ON DELETE SET NULL`.
|
||||
- nullable unique `groupId` on ShareLink with `ON DELETE CASCADE`.
|
||||
- the indexes Prisma expects.
|
||||
2. Review the generated SQLite table-rebuild SQL by hand for preserved rows, foreign keys, defaults, and unique indexes.
|
||||
3. Publish a one-time preflight/adoption script that classifies a database as:
|
||||
- migration-tracked and missing the group schema: apply the migration normally;
|
||||
- already schema-pushed and exactly matching the intended DDL: after backup and exact introspection, mark the new migration applied with `prisma migrate resolve --applied`;
|
||||
- partially matching or otherwise inconsistent: stop with diagnostics and require manual recovery; never guess or auto-resolve.
|
||||
4. Keep the adoption operation explicit. The normal entrypoint should not silently mutate migration history based on table existence.
|
||||
|
||||
Verification:
|
||||
|
||||
- Fresh empty DB: `prisma migrate deploy` succeeds; `prisma migrate diff --from-migrations ... --to-schema ...` is empty; Deck, QuizSet, ShareLink, and MaterialGroup queries succeed.
|
||||
- Populated pre-group DB: seed classes/decks/quizzes/shares, apply the new migration, and prove every row/count/relationship is preserved.
|
||||
- Disposable `db push`-style DB: introspect exact equivalence, resolve the migration, run deploy again, and prove an empty schema diff.
|
||||
- Partial/conflicting DB: preflight exits nonzero without changing schema or `_prisma_migrations`.
|
||||
|
||||
#### 1B. Make session configuration unforgeable and setup explicit
|
||||
|
||||
1. Centralize session option construction so `src/lib/auth.ts` and `src/proxy.ts` cannot drift.
|
||||
2. Require a nonblank, at-least-32-character production `SESSION_SECRET` at runtime. Do not require or bake the real secret during `docker build`.
|
||||
3. Make the container entrypoint fail before migrations/server startup when the secret is absent or equals the known fallback. Make Compose use required-variable expansion.
|
||||
4. Allow a development-only fallback only when `NODE_ENV !== "production"`, clearly label it non-production, and cover both branches with tests.
|
||||
5. Replace `pathname.includes(".")` with exact public/static rules. Ensure every `/api/*` path other than the intentional auth endpoints remains protected even when the URL contains a dot or encoded dot.
|
||||
6. Construct the unauthenticated redirect first and bind `getIronSession` to that response before `destroy()`, so the returned redirect carries the clearing cookie.
|
||||
7. Honor a valid `ADMIN_PASSWORD_HASH` when the database has no configured password, or require an explicit one-time setup flag/token. The default production state must not let the first remote request select the admin password.
|
||||
8. Prefer a local console/CLI initiation for password reset. If the HTTP request endpoint remains, use a global single-user throttle, never replace an unexpired token, rate-limit verify/complete, and do not trust arbitrary `x-forwarded-for` unless a trusted proxy is explicitly configured.
|
||||
|
||||
Verification:
|
||||
|
||||
- Production startup with missing, blank, short, or known fallback secret exits nonzero before serving.
|
||||
- A cookie sealed with the old fallback does not authenticate when a real secret is configured.
|
||||
- Valid login, logout, reset completion, and session-generation invalidation still work.
|
||||
- Dot-containing protected page/API requests redirect or reject; known static assets still load.
|
||||
- A stale-generation response contains a `Set-Cookie` deletion header.
|
||||
- Pre-provisioned hash rejects a different first password and reports setup complete.
|
||||
|
||||
#### 1C. Make the image and Compose definitions deterministic
|
||||
|
||||
1. Add `.dockerignore` for `node_modules`, `.next`, VCS metadata, environment files, databases/data, coverage, temporary caches, and audit scratch artifacts while retaining source, Prisma schema/migrations, lockfile, and public assets.
|
||||
2. Change the dependency stage to `npm ci`.
|
||||
3. Remove `npm install prisma@^7.8.0` from the runner. Ship a Prisma CLI/runtime installed from `package-lock.json` at the exact repository version. If Prisma is needed at runtime, classify it as a runtime dependency and prune/copy dependencies deterministically rather than re-resolving them.
|
||||
4. Map `3000:3726` in production Compose. Keep `PORT=3726` and `EXPOSE 3726` internally unless there is a deliberate decision to standardize everything on 3000.
|
||||
5. Rename `docker-compose.override.yml` to a non-auto-merged development filename such as `docker-compose.dev.yml`. Document exact production and development invocations.
|
||||
6. Add a minimal internal health route that performs schema-aware DB checks (including a query that touches `Deck.groupId` and MaterialGroup), returning no sensitive details. The container health check must call the actual internal port.
|
||||
7. Add a lock-safe SQLite backup command using the SQLite backup API, plus a documented restore drill. A raw copy of only the main DB while WAL writes are active is not an acceptable backup procedure.
|
||||
|
||||
Verification:
|
||||
|
||||
- `docker compose -f docker-compose.yml config` shows the production command, production environment, and `3000:3726` only.
|
||||
- The explicit development Compose command shows dev mode and source mounts.
|
||||
- Build on a Windows-host context and run on Linux; requiring `better-sqlite3` and `argon2` succeeds in the final image.
|
||||
- Fresh-volume container becomes healthy and `/login` returns 200 through host port 3000.
|
||||
- A migration-drifted disposable volume stays unhealthy with an actionable log.
|
||||
- Backup a populated disposable volume, destroy the disposable container/volume, restore it, and compare row counts plus representative content.
|
||||
|
||||
#### 1D. Add CI gates in a sequence that can actually be green
|
||||
|
||||
1. Gate `npm ci`, `npm test`, Prisma validate, migration/schema drift, and `npm run build` immediately.
|
||||
2. Build the production image, start it on a fresh disposable volume with a generated CI secret, wait for health, and smoke `/login` plus setup status.
|
||||
3. Tag immutable images by commit SHA; optionally move the mutable `latest` tag only after all gates pass.
|
||||
4. Add strict lint only after the existing 9-error baseline is resolved in an explicit cleanup or after a changed-files lint gate is implemented. Never report baseline lint as passing.
|
||||
|
||||
### Phase 2 — make quiz attempts authoritative and immutable
|
||||
|
||||
Addresses FIN-03, FIN-11–13, FIN-20, FIN-26, FIN-27, and FIN-53.
|
||||
|
||||
1. Replace the client-controlled `isPartialRetake` contract with an explicit ordered `questionIds` scope. The server validates that IDs are unique and belong to the quiz, then derives whether the attempt is partial.
|
||||
2. Accept a structured answers object at the route boundary and persist only canonical server-serialized JSON. Validate that:
|
||||
- each key is in the attempted scope;
|
||||
- every selected option belongs to that question;
|
||||
- selected IDs are unique;
|
||||
- multiple-choice has at most one selection;
|
||||
- missing answers score zero rather than silently disappearing.
|
||||
3. Make the in-viewer “Retake Missed” and “Retake Full Quiz” set explicit local attempt scope. Do not infer retake state from the original prop after the viewer has transitioned internally.
|
||||
4. Add an `isFinishing` state/ref, disable the Finish button during submission, and show a retryable error without discarding answers.
|
||||
5. Move attempt creation and full-attempt SEQUENTIAL-progress deletion into one service transaction. Partial retakes must not overwrite or delete a full in-progress session.
|
||||
6. Guard zero-correct SATA scoring with zero points and deduplicate selections defensively in the pure scorer even though the route also validates them.
|
||||
7. Add an optional immutable review snapshot to QuizAttempt for new attempts: attempted question/order, prompt/category/rationale, options and correctness, selections, and per-question points. Render new history from that snapshot; keep a clearly tested legacy fallback for old rows.
|
||||
8. Stabilize QuizViewer initialization. A parent render that changes topics or another surrounding control must not regenerate option order, clear answers, or reset the index. A deliberate restart/retake must use a new session key.
|
||||
|
||||
Focused verification:
|
||||
|
||||
- Full 5-question attempt scores out of 5 and clears only its matching full progress row.
|
||||
- Two-question retake scores out of 2, is stored as partial, and leaves full progress untouched.
|
||||
- An unanswered question in the explicit scope scores zero and remains in the review snapshot.
|
||||
- Duplicate/unknown question or option IDs, wrong shapes, and client-forged partial flags return 400 without creating an attempt.
|
||||
- Delayed double-click Finish produces exactly one attempt.
|
||||
- Changing or deleting current quiz content after an attempt does not change the stored score, category breakdown, or review copy for snapshot-backed attempts.
|
||||
- Parent rerender preserves question order, option order, current index, and answers; explicit restart changes only the intended session.
|
||||
|
||||
### Phase 3 — make progress and flashcard resume race-safe
|
||||
|
||||
Addresses FIN-04, FIN-14, FIN-15, FIN-21, FIN-34, and FIN-36.
|
||||
|
||||
1. Replace or correct `filterAndClampOrder` with a normalization function that accepts saved order, live IDs, saved index, results/answers, and completion state. It must:
|
||||
- remove stale and duplicate IDs;
|
||||
- preserve the saved current ID when it still exists;
|
||||
- when it was deleted, select the next surviving saved card, otherwise the previous survivor;
|
||||
- preserve `index === oldOrder.length` as completion by returning `index === newOrder.length`;
|
||||
- filter result/answer keys to live IDs;
|
||||
- safely fall back when any JSON field is malformed.
|
||||
2. Apply normalization before any viewer state initializer calls `JSON.parse`. Treat corrupt legacy progress as recoverable, show a small restore warning, and allow a fresh start.
|
||||
3. Add Zod schemas for GET/PATCH/DELETE progress inputs, enum values, nonnegative index, content-specific JSON shape, and referenced content existence.
|
||||
4. Add `sessionId` and monotonic `revision` to progress. The server accepts only a newer revision for the same session; a stale session cannot overwrite or delete a newer session. Serialize client saves but retain server-side revision enforcement because request completion order is not guaranteed.
|
||||
5. On the last flashcard, persist `currentIndex = order.length`. Restoring that row must show the existing completion summary, not the last card.
|
||||
6. Await restart deletion before remount/navigation. Make DELETE conditional on the session being cleared so a late old request cannot delete fresh progress.
|
||||
7. Disable Previous, shuffle, and repeated grading during the 350 ms transition, and clear/cancel the timer on restart/unmount.
|
||||
|
||||
Focused verification:
|
||||
|
||||
- Stale ID before, at, and after the current index all resume on the correct logical card.
|
||||
- All saved cards deleted yields a clear empty/completed state rather than a blank viewer.
|
||||
- Completed sessions reopen on the summary; restart opens card 1.
|
||||
- Invalid JSON and invalid PATCH payloads produce a safe UI fallback or 400, never a render crash/500.
|
||||
- Deliver revisions 3, 1, and 2 in that order; the DB retains revision 3.
|
||||
- Delay an old-session DELETE until after a new-session PATCH; the new session remains.
|
||||
- Grade then immediately try Previous/restart; no index jump or stale result write occurs.
|
||||
|
||||
### Phase 4 — enforce group, ordering, and sharing invariants on the server
|
||||
|
||||
Addresses FIN-18, FIN-19, FIN-32, FIN-33, FIN-41, FIN-49, FIN-50, and FIN-52.
|
||||
|
||||
1. Move reorder logic into focused services. The route must identify the owning class and content type from the database, not trust the client.
|
||||
2. Validate every target group exists, belongs to the same class, and matches DECK/QUIZ type. Reject duplicate IDs, foreign-class IDs, type mismatches, and unknown IDs.
|
||||
3. Prefer an ordered list/group assignment contract and compute contiguous `sortOrder` values server-side. If a complete-set contract is required, verify completeness explicitly.
|
||||
4. In the library, capture origin group before any update, update state immutably, await `res.ok`, disable conflicting mutations in flight, and rollback/refetch on failure.
|
||||
5. Delete a group in one transaction: capture affected items, delete/reassign through the FK behavior, then renumber Uncategorized items deterministically. Add `createdAt`/`id` tie-breakers to reads so legacy duplicates are stable.
|
||||
6. Add a keyboard-accessible Move-to-group menu on every item. Preserve drag-and-drop for pointer users and keep drag listeners off action controls.
|
||||
7. Validate ShareLink target type and target existence. Enforce exactly one populated target in service logic; consider CHECK constraints in a later SQLite migration after compatibility testing.
|
||||
8. Retain the shared-page class/type/token checks and additionally assert each selected group item has the group’s class and expected content type.
|
||||
9. Pass the share token/session namespace to shared viewers and include it in localStorage keys. Avoid copying an old item-only session into a different token namespace without explicit user confirmation.
|
||||
10. Preserve named-group ordering as `sortOrder desc, createdAt desc`, prepend newly created groups locally, and render Uncategorized last. Remove arbitrary `sortOrder` from the ordinary group rename PATCH contract.
|
||||
11. Add a concise ShareMenu warning that a shared quiz necessarily sends answer/rationale data to the recipient’s browser for local grading.
|
||||
|
||||
Focused verification:
|
||||
|
||||
- Cross-class and cross-type group assignment returns 400/409 and changes no rows.
|
||||
- A failing reorder restores/refetches the visible order.
|
||||
- Deleting a group yields unique contiguous Uncategorized item order and does not delete decks/quizzes.
|
||||
- Newer named groups stay above older groups; Uncategorized remains last in quizzes, flashcards, and import selectors.
|
||||
- A keyboard-only user can move an item between two groups and hear/see confirmation.
|
||||
- Invalid share target types and missing IDs create zero rows.
|
||||
- A tampered group share cannot render a foreign-class item.
|
||||
- Two tokens for the same content maintain independent local sessions.
|
||||
|
||||
### Phase 5 — validation, SRS integrity, and user-visible error handling
|
||||
|
||||
Addresses FIN-16, FIN-28–31, FIN-35, FIN-37, FIN-43–48, and the measurable part of FIN-17.
|
||||
|
||||
1. Reuse shared Zod schemas for card create/edit, deck/quiz create/edit, classes, imports, and material groups. Trim before minimum checks and add documented maximums for names, descriptions, Markdown content, options, questions, and cards.
|
||||
2. Reject a Create-tab submission containing any partially filled card; identify the row(s) instead of silently dropping them.
|
||||
3. Enforce at least two correct options for imported SATA questions while leaving existing stored questions readable. Add a targeted message explaining how to repair invalid generated JSON.
|
||||
4. Narrow Prisma error mapping: P2025 becomes 404, P2002 becomes 409 where appropriate, validation is 400/422, and unexpected failures remain 500 with non-sensitive server diagnostics.
|
||||
5. In SRS review, verify the submitted card is the queue-eligible card for the current set/time/new-card allowance (including deliberate learn-ahead). Retain the existing state-version comparison. Map concurrent first-review P2002 to 409 and return/refetch current study state.
|
||||
6. Refresh SRS membership on focus and after same-tab deck changes.
|
||||
7. Add abortable, checked fetch helpers or a small consistent pattern for Dashboard, GenerateTab, ShareMenu, logout, and class library requests. Distinguish loading, empty, error, and retry states.
|
||||
8. Tie library responses to the active `classSlug`; abort or ignore stale responses. Bound or remove module-level caches that can outlive their class data.
|
||||
9. Guarantee class slug generation produces a non-empty unique slug, for example a stable `class-<short-id>` fallback. Keep existing slugs stable on rename.
|
||||
10. Add `data/`, `study.db`, and sidecars to `.gitignore`. Confirm `out.css`, `temp.css`, and `test.css` are unreferenced scratch artifacts before removing them in a separate cleanup change.
|
||||
11. For performance, first seed a disposable benchmark DB approximating expected upper use (for example 100k activity rows, 10k cards, and several SRS sets). Add date predicates/aggregation and narrower SRS queries only where the measured request/poll budget is exceeded. Preserve long-streak correctness when limiting the activity window.
|
||||
|
||||
Focused verification:
|
||||
|
||||
- Whitespace-only and oversized content is rejected with an actionable client error and no partial write.
|
||||
- SATA 1-correct import fails; 2+-correct passes; multiple-choice still requires exactly one.
|
||||
- SRS cannot review a not-due/non-selected/new-limit-exhausted card; valid due and learn-ahead reviews still work.
|
||||
- Concurrent first review produces one success and one handled conflict, not a generic 500.
|
||||
- Focus refresh removes a deleted deck from the SRS set UI.
|
||||
- Failed dashboard/settings/share/logout requests have visible, retryable states and no unhandled promise rejection.
|
||||
- Rapid A→B class navigation cannot render A data under B.
|
||||
- Creating a class named only punctuation yields a navigable, unique slug; rename does not break the old URL.
|
||||
- Performance tests record query counts and latency before/after and prove the 53-week display plus longer current streak remain correct.
|
||||
|
||||
## Cross-cutting release verification
|
||||
|
||||
Run after every phase, with focused tests first and the full suite last.
|
||||
|
||||
Automated gates:
|
||||
|
||||
1. `npm.cmd test`
|
||||
2. `npx.cmd prisma validate`
|
||||
3. migration deploy + empty migration/schema diff against a fresh disposable DB whenever schema changes
|
||||
4. `npm.cmd run build`
|
||||
5. `git diff --check`
|
||||
6. production image build, fresh-volume health wait, and HTTP smoke when Docker/deployment files change
|
||||
7. lint against changed files; full `npm.cmd run lint` only becomes blocking after the known baseline is cleared
|
||||
|
||||
Manual browser matrix for affected phases:
|
||||
|
||||
1. Authenticated routes and corresponding public/shared routes.
|
||||
2. Fresh, resumed, completed, restarted, partial-retake, and stale/deleted-content sessions.
|
||||
3. Loading, empty, server-error, malformed-persisted-data, and retry states.
|
||||
4. Light and dark themes.
|
||||
5. Narrow mobile viewport with no horizontal overflow.
|
||||
6. Keyboard-only navigation, including group moves and all modified icon controls.
|
||||
7. Throttled-network checks for Finish, autosave, restart, class navigation, reorder, and logout.
|
||||
8. Two-tab checks for progress revisions, SRS conflicts, and focus refresh.
|
||||
|
||||
Release/rollback rules:
|
||||
|
||||
- Back up and restore-test the SQLite database before the first migration-bearing release.
|
||||
- Deploy schema and code as one versioned release; do not run a newer generated Prisma client against an older database.
|
||||
- Keep the prior image and verified pre-migration backup until post-deploy smoke and representative data checks pass.
|
||||
- If migration preflight sees a partial or unknown schema state, stop. Do not run `db push`, edit an applied migration, reset the database, or auto-mark the migration applied.
|
||||
|
||||
## Explicitly deferred or rejected work
|
||||
|
||||
- **FIN-38:** no timezone-setting work without a product requirement to support a non-Arizona study day.
|
||||
- **FIN-42:** no history rewrite without an offline sensitivity review and explicit remote/clone coordination.
|
||||
- **FIN-51:** no cosmetic state-initialization change unless it avoids both hydration mismatch and the existing effect-rule class of lint failures.
|
||||
- **FIN-22 retry proposal:** no automatic migration retry/backoff for schema conflicts; fail fast with health diagnostics and the adoption/recovery runbook.
|
||||
- No changes to the audit’s already rejected hypotheses (global API auth absence, split-brain SQLite paths, broken cascades/transactions, Markdown XSS, or client/server import violations) unless new evidence appears.
|
||||
|
||||
## Recommended delivery slices
|
||||
|
||||
Keep the implementation reviewable rather than landing all remediation at once:
|
||||
|
||||
1. **Release blocker:** FIN-01/02/05/06/07/23/25 plus migration adoption, container smoke, and backup prerequisites.
|
||||
2. **Quiz correctness:** FIN-03/11/12/13/20/26/27/53.
|
||||
3. **Progress integrity:** FIN-04/14/15/21/34/36.
|
||||
4. **Group/share integrity:** FIN-18/19/32/33/41/49/50/52.
|
||||
5. **Auth recovery and deployment posture:** FIN-08/09/10/24/39/40.
|
||||
6. **Validation, SRS, UI resilience, and measured performance:** the remaining accepted items.
|
||||
|
||||
Each slice should be independently releasable, have its own focused regression tests, and finish with the cross-cutting gates above.
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
# Subagent Reports
|
||||
|
||||
Full specialist reports were produced by 10 read-only subagents. Each returned a structured markdown report with a findings table and per-finding detail blocks (ID, severity, confidence, files:lines, scenario, expected/actual, root cause, impact, evidence, test coverage, fix direction, complexity, regression test, runtime-confirmation flag). The parent agent re-verified the Critical/High claims directly (see VERIFICATION_LOG.md) and consolidated/deduplicated into `FINDINGS.json` (53 findings).
|
||||
|
||||
## Report index (all reports were reviewed in full by the parent)
|
||||
|
||||
| # | Auditor | Ref | Key output |
|
||||
|---|---|---|---|
|
||||
| 1 | DB / migrations / transactions / cascades / drift | sa_20260806_054030_000000000_5ed877563a0d | **DBAUD-01 Critical migration drift** (MaterialGroup/groupId never migrated; commit 7af0935 db-push artifact); DBAUD-02 junk ShareLink rows; DBAUD-03 dev.db in git history; verified-safe: cascades, transactions, path resolution, single-row AuthSecurity |
|
||||
| 2 | Quiz scoring / attempts / retakes / progress | sa_20260806_054030_000000000_e75a8ce575f9 | **QUIZ-01 High retake-scoring bug** (in-viewer retake never sets retakeIds); QUIZ-02..09 (unvalidated answersJson, double-submit, progress races, dead filter, div-by-zero, historical recompute, multi-tab) |
|
||||
| 3 | Flashcards / SRS / progress / deletion / ordering | sa_20260806_054030_000000000_a222804c133c | CARD-01..11: dead stale-id filter (stuck sessions), unsequenced saves, completed-session resume, SRS due/limit not enforced, unbounded scans, Arizona day boundary, restart race, P2002, animation race, focus staleness |
|
||||
| 4 | Auth / protected routes / API authz / sharing | sa_20260806_054030_000000000_aab0c59dbad3 | **AUTH-01 Critical hardcoded session secret**; AUTH-02 dot-bypass; AUTH-03 reset abuse; AUTH-04 first-login takeover + dead ADMIN_PASSWORD_HASH; AUTH-05..10 (cookie-clear loss, cross-class group membership, share validation, secure flag, no auth tests, answer-key exposure); verified proxy compiled as middleware |
|
||||
| 5 | Imports / exports / generation / Zod / malformed input | sa_20260806_054030_000000000_5e72c2713989 | IMPT-01..06: dead filterAndClampOrder, unvalidated /api/progress + unguarded JSON.parse, card edit endpoints without Zod, no size caps, SATA doc mismatch, override-field 500s; verified: shared schemas, atomic imports, no XSS, no id regeneration |
|
||||
| 6 | Material groups / ordering / drag-drop / deletion / orphans | sa_20260806_054030_000000000_9ae1f3dfa282 | **GRP-01 Critical drift** (independent confirmation); GRP-02..07: unvalidated groupId/sortOrder, fire-and-forget reorder, sortOrder duplicates after group deletion, keyboard a11y, desc-convention fragility; verified: FKs enforced, uncategorized mapping, cascade safety |
|
||||
| 7 | Frontend state / refresh / races / localStorage / boundaries | sa_20260806_054030_000000000_0e17fd155702 | FE-01..11: optimistic reorder without rollback, CRUD res.ok ignored, shared quiz reshuffle (unstable prop), finish double-submit, progress race, **FE-06 (later refuted — proxy exists)**, dashboard fetch gap, unguarded JSON.parse, effect fetches, cross-class race, collapse flash; verified: shared viewers read-only, localStorage clean, no boundary violations |
|
||||
| 8 | Test quality / coverage / false confidence | sa_20260806_054030_000000000_cd3652deb3f7 | TEST-01..10: false-confidence SRS test, zero tests for scoring/auth/services/routes, no DB harness, CI never runs tests; ranked missing-test gaps by impact |
|
||||
| 9 | Docker / startup / env / scripts / production / recovery | sa_20260806_054030_000000000_f49cdaacaabf | OPS-01..10: port mismatch 3726 vs 3000:3000, entrypoint fragility, session secret in prod path, dead ADMIN_PASSWORD_HASH, non-reproducible installs, no .dockerignore (win32 leak), no healthcheck/backup, override auto-merge, .gitignore gaps, CI without gates; argon2/standalone tracing verified OK |
|
||||
| 10 | Adversarial review | sa_20260806_055608_000000000_c550c742d5dc | Challenged all Critical/High findings: confirmed drift, secret, retake, port, dockerignore, override; weakened OPS-02 (fresh-volume migrate succeeds) and FE-01 severity; **refuted FE-06** (proxy active — verified in compiled .next artifacts); added ADV-01..10 (retake progress pollution, activity scan, logout, dashboard crash, slugify empty slug, unguarded parses, completed-resume, localStorage key collisions, first-login race, progress validation) |
|
||||
|
||||
All reports are preserved in full in the session; their findings are consolidated (deduplicated, source IDs preserved) in `FINDINGS.json` and `CONFIRMED_FINDINGS.md`.
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
# Test Gaps
|
||||
|
||||
Current suite: 6 files / 30 tests — 20 in `src/lib/arcade/*` (out of scope) + 10 in `src/lib/spacedRepetition.test.ts` (pure scheduling math only). **No test touches a service, an API route, the database, auth, scoring, imports, or progress.** CI never runs `npm test`. Gaps ranked by user-impact × regression-likelihood.
|
||||
|
||||
| ID | Area | Gap | Risk it would fail to catch | Suggested test |
|
||||
|---|---|---|---|---|
|
||||
| TEST-GAP-01 | Auth (FIN-02/08/09/10) | `authService.ts` (login, lockout, first-login provisioning, reset token, generation bump) and session config have zero tests | Session-secret fallback regression, lockout bypass, generation-bump loss (stolen sessions stay valid) | Extract + unit-test `getLockoutDuration` thresholds, `parseResetTokenRecord`; service tests with temp SQLite + fake timers: 5 wrong passwords → 423; locked-out login rejected; success resets counter; `completePasswordReset` bumps generation |
|
||||
| TEST-GAP-02 | Scoring (FIN-03/11/26/27) | `src/lib/scoring.ts` SATA partial credit has zero tests | Wrong grades silently persisted into every attempt — permanent data corruption | `scoreQuestion`: SATA 2/4 correct select both → 1; 1 correct + 1 wrong → 0; over-select → 0; MC multi-select first-wrong → 0; `scoreQuiz` with missing question keys; 0-correct SATA → 0 (not NaN) |
|
||||
| TEST-GAP-03 | Quiz attempt persistence (FIN-03/12/13) | `POST /api/quizzes/[id]/attempt` + partial-retake filter + `progressService.upsertProgress` asymmetry (DECK writes cardResultsJson, QUIZ writes answersJson) have zero tests | Retake-scoring regression (FIN-03 class), duplicate attempts, progress column clobbering | Route/service test: partial retake answering 2 of 5 → maxScore 2, score from those 2; upsert round-trip preserves the respective JSON columns |
|
||||
| TEST-GAP-04 | SRS service (FIN-16/35) | `spacedRepetitionService.ts` (reviewCard optimistic concurrency, findNextCard precedence, removeDeck transaction, addDeck validations) has zero tests — only the pure lib is tested | Double-review stale write, due/new-card precedence break, cross-class membership, P2002 → 500 | Seed class→deck→cards→set on temp DB: stale `expectedStateVersion` → 409; due before new before learn-ahead; new card with limit 0 rejected; removeDeck atomically deletes states |
|
||||
| TEST-GAP-05 | Import/repair pipeline (FIN-28/29/30) | `jsonRepair.ts`, `importSchemas.ts`, `shuffle.ts`/`filterAndClampOrder` have zero tests | AI-import regression (fences/repair), schema constraint drift (MC exactly-one), resume clamp bugs | jsonRepair fence/trailing-comma/broken-JSON cases; schema whitespace/oversize/exactly-one cases; `filterAndClampOrder` stale-id + overflow cases |
|
||||
| TEST-GAP-06 | Rate limiter (FIN-10) | `rateLimiter.ts` has zero tests | Brute-force guard silently disabled or locking the owner out | Fake timers: 10 allowed / 11th blocked with retryAfterMs; window expiry; per-key isolation; custom limits |
|
||||
| TEST-GAP-07 | Integration harness (FIN-01/21/25) | No route tests, no DB-backed tests, no setup file; `src/lib/db.ts` defaults to `file:./dev.db` (a naive service test would touch the real dev DB) | Migration drift (FIN-01) — nothing would catch a schema/migration mismatch; progress/share/attempt route validation gaps | `setupFiles` + `DATABASE_URL=file:<tmp>` + `prisma migrate deploy` in globalSetup; route tests with `NextRequest`: attempt 200/400/404, reviews 409, share unknown token, login 429 |
|
||||
| TEST-GAP-08 | CI gate (FIN-25) | `.forgejo/workflows/build.yml` runs no tests/lint/prisma validate; no coverage config; no drift check | Entire suite can regress with a green pipeline; FIN-01-class drift ships | Add `npm ci && npm test && npm run lint && npx prisma validate` job; `prisma migrate diff --from-migrations --to-schema-datamodel` gate; container smoke (curl /login on fresh volume); coverage floor |
|
||||
| TEST-GAP-09 | False confidence (FIN-53) | `spacedRepetition.test.ts:15-23` "distinct valid state for every rating" never compares ratings | `scheduleRating` collapsing all ratings to one schedule passes the suite | Assert pairwise due ordering AGAIN < HARD < GOOD < EASY and distinct due timestamps; graduated-state lapse case |
|
||||
|
||||
## Test-quality positives (verified)
|
||||
|
||||
- `spacedRepetition.test.ts` is deterministic (fixed `now`, `enable_fuzz: false`, no `Math.random`), tests the real pure module, and asserts exact FSRS intervals.
|
||||
- Arcade tests (out of scope) use seeded shuffles and meaningful assertions.
|
||||
- No test uses wall-clock time, locale, or network — no flakiness found.
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
# Unverified Risks
|
||||
|
||||
Risks that could not be fully confirmed within audit constraints (need a browser, a container run, a live deployment, or timing-dependent reproduction). All are code-verified as plausible; the missing piece is runtime confirmation.
|
||||
|
||||
| ID | Title | Why unverified | Suggested verification |
|
||||
|---|---|---|---|
|
||||
| FIN-02 | Hardcoded fallback `SESSION_SECRET` → session forgery | Code path fully traced (seal/verify semantics verified in iron-session), but no live production deployment exists here to demonstrate an actual forged-cookie login | Boot production build without `SESSION_SECRET`, forge a cookie with the fallback password (iron-webcrypto seal script), assert full access; then with a real secret assert rejection |
|
||||
| FIN-06 | No `.dockerignore` → win32 modules in Linux image | Mechanics verified (PE32+ DLL present in local node_modules; Dockerfile COPY order), exact failure mode (build error vs `ERR_DLOPEN_FAILED`) not observed | Run `docker build .` on this Windows host; inspect `better_sqlite3.node`/argon2 prebuilds in the image (must be ELF) |
|
||||
| FIN-13 | Progress PATCH-after-DELETE race | Timing-dependent; needs throttled network | DevTools throttling: submit final answer + Finish immediately; inspect `StudyProgress` after |
|
||||
| FIN-14 | Out-of-order progress PATCHes | Timing-dependent | DevTools throttling with rapid grading; reload and compare resume point/results |
|
||||
| FIN-22 | Entrypoint migrate crash loop / runner CLI install | Requires container build + run on `node:22-slim` (docker CLI unavailable in audit environment) | `docker build` + fresh-volume run (assert 200 + `_prisma_migrations`); corrupt-volume run (assert actionable failure, not silent loop) |
|
||||
| FIN-24 | Healthcheck/backup gaps | Behavior observable only in a real container run | Compose up with a corrupt volume; observe restart loop and absence of health status |
|
||||
| FIN-32 | Group-delete sortOrder duplicates | Deterministic from code; visual impact needs a browser | Delete a group in the app, inspect Uncategorized order + DB sortOrders |
|
||||
| FIN-33 | Keyboard cross-group moves impossible | Deterministic from code (empty `handleDragOver`), needs manual keyboard test | Tab to a drag handle, attempt keyboard cross-group move |
|
||||
| FIN-34 | Restart DELETE race | Timing-dependent | Throttled network: Restart, grade first card, inspect progress row |
|
||||
| FIN-36 | Previous-card animation race | Timing-dependent (350 ms window) | Grade then immediately click Previous; observe index jump |
|
||||
| FIN-37 | SRS page stale on focus | Deterministic from code; browser needed to observe | Two tabs: delete deck in one, focus the other |
|
||||
| FIN-39 | Proxy destroy cookie lost on redirect | Deterministic from code; browser needed to observe Set-Cookie | Stale-generation cookie → follow redirect → inspect response headers |
|
||||
| FIN-40 | Secure cookie flag off | Deterministic from code (flag logic); deployment-dependent | Inspect `Set-Cookie` in a production container |
|
||||
| FIN-46 | Cross-class fetch race | Timing-dependent | Throttled network + fast class switching |
|
||||
| FIN-48 | Empty slug / stale slug on rename | Deterministic from code (slugify verified); UI behavior needs browser | Create class named "!!!", try to navigate to it; rename a class, check old URL |
|
||||
| FIN-49 | Shared-viewer localStorage key collisions | Code-verified key construction; symptom needs browser | Open two share tokens for the same content, answer in one, reload the other |
|
||||
| FIN-51 | Collapsed-groups expand flash | Cosmetic; browser-only | Visit library with saved collapsed state |
|
||||
|
||||
## Items verified safe (hypotheses disproven — recorded for completeness)
|
||||
|
||||
- SQLite relative-path split-brain between Prisma CLI and app (both cwd-relative → same `dev.db`; Docker uses absolute path) — verified safe.
|
||||
- Foreign-key enforcement (better-sqlite3 compiled with `SQLITE_DEFAULT_FOREIGN_KEYS=1`) — cascades/SetNull fire; verified safe.
|
||||
- Transactionality of deck/quiz/attempt/reorder writes — nested creates + `$transaction`; verified safe.
|
||||
- `StudyProgress`/`ShareLink` NULL-uniqueness — service-level guards prevent duplicates via app paths; only direct API misuse (FIN-41/FIN-21) can create junk rows.
|
||||
- Client/server boundary violations — none found (no client import of `@/lib/db`/services/prisma).
|
||||
- XSS via Markdown — react-markdown without `rehype-raw`; no `dangerouslySetInnerHTML` on user data; verified safe.
|
||||
- Password-reset nonce/token crypto (192-bit token, SHA-256 digest, `timingSafeEqual`, expiry) — sound; abuse vectors are FIN-10.
|
||||
- SRS optimistic concurrency (`expectedStateVersion` → 409, rollback via refetch) — correct.
|
||||
- Migrations are purely additive; no NOT NULL-without-default, no DROP — safe on populated migration-tracked DBs (except FIN-01 drift).
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
# Verification Log
|
||||
|
||||
Chronological record of every command run, its outcome, and classification (baseline / environment / possible defect).
|
||||
|
||||
## Environment notes
|
||||
|
||||
- OS: Windows, shell: bash (git-bash)
|
||||
- `node_modules` was **absent** at audit start → `npm test` initially failed with `'vitest' is not recognized`. This is an environment condition, not a repo defect. `npm ci` completed successfully during the first audit session (518 packages present).
|
||||
- No existing application database (`dev.db*`) exists anywhere in the repo — no real user data to protect; the migration-chain test uses a brand-new temp DB under `audit-results/tmp`.
|
||||
- `src/generated/prisma/` (tracked) hashed BEFORE any prisma command: `audit-results/tmp/generated-before.sha256`.
|
||||
|
||||
## Entries
|
||||
|
||||
| # | Time | Command | Result | Classification |
|
||||
|---|---|---|---|---|
|
||||
| 1 | session start | `git status --short` | clean; only `?? .reasonix/` and `?? audit-results/` untracked | baseline |
|
||||
| 2 | session start | `npm test` (first run, prior session) | FAILED: `'vitest' is not recognized` (no node_modules) | environment |
|
||||
| 3 | session start | `npm ci` (background, prior session) | completed; 518 packages in node_modules | environment |
|
||||
| 4 | 22:36 | `npx prisma validate` | PASS — "The schema at prisma\schema.prisma is valid" (exit 0) | baseline |
|
||||
| 5 | 22:36 | `npm test` | PASS — 6 files, 30 tests, all green (arcade ×20, spacedRepetition ×10) | baseline |
|
||||
| 6 | 22:37 | `npm run lint` | 28 problems: 9 errors, 19 warnings (react-hooks/set-state-in-effect errors + unused-vars warnings). All pre-existing; matches AGENTS.md known baseline. | baseline |
|
||||
| 7 | 22:37 | `npm run build` (background) | PASS — route table emitted, no compile errors | baseline |
|
||||
| 8 | 22:38 | `DATABASE_URL=file:./audit-results/tmp/audit-migration.db npx prisma migrate deploy` | PASS — all 4 migrations applied to brand-new temp DB | baseline |
|
||||
| 9 | 22:38 | `sha256sum -c audit-results/tmp/generated-before.sha256` | PASS — every generated Prisma file byte-identical (all ": OK") | baseline |
|
||||
| 10 | 22:38 | `git status --short` | only untracked `.reasonix/`, `audit-results/`; no tracked modifications | baseline |
|
||||
| 11 | 22:38 | `git diff --check` | clean (no whitespace errors) | baseline |
|
||||
| 12 | 22:40 | `git log --all --oneline -- prisma/migrations/` + `git show 7af0935 --stat` | Confirms drift: commit 7af0935 changed `prisma/schema.prisma` (+31) and `dev.db` (139264→155648 bytes) with **no migration**; `dev.db` present in 8 commits of history | possible defect (FIN-01, FIN-42) |
|
||||
| 13 | 22:41 | `node audit-results/tmp/inspect-db.cjs audit-results/tmp/audit-migration.db` | Temp DB (built solely by `migrate deploy`) has **no** `MaterialGroup` table and **no** `groupId` column on Deck/QuizSet/ShareLink | possible defect (FIN-01) |
|
||||
| 14 | 22:42 | `node audit-results/tmp/reproduce-drift.cjs` | Prisma-shaped queries fail: `no such column: Deck.groupId`, `no such table: MaterialGroup`, `no such column: ShareLink.groupId` — fresh-deploy breakage proven | possible defect (FIN-01) |
|
||||
| 15 | 22:45 | `node --check audit-results/tmp/inspect-db.cjs && node --check audit-results/tmp/reproduce-drift.cjs` | diagnostic scripts syntax-valid | baseline |
|
||||
| 16 | 22:50 | `node audit-results/tmp/validate-findings.cjs` | `FINDINGS.json` VALID JSON — 53 findings: 2 Critical, 5 High, 18 Medium, 25 Low, 3 Observation; no duplicate ids | baseline |
|
||||
| 17 | 23:00 | `git status --short` + `git diff --check` (final) | Only untracked `.reasonix/`, `audit-results/`; no tracked file modified; diff clean | baseline |
|
||||
|
||||
## Classification legend
|
||||
|
||||
- **baseline** — repository behaves as expected; recorded for reference.
|
||||
- **environment** — failure caused by local machine state, not repo code.
|
||||
- **possible defect** — outcome may indicate a repo defect; investigated further in findings.
|
||||
Binary file not shown.
|
|
@ -1,26 +0,0 @@
|
|||
cb737289f5b5f6cdb8b90fc23d2c89ebd41d6602ce99e88bf6dfd751beab7103 *src/generated/prisma/browser.ts
|
||||
2ea331124cc56c9c9a360b4a85f42f029992811ece7efe9db0ec1300a43f59c3 *src/generated/prisma/client.ts
|
||||
f1f6280ff65c1e8aab5ce30307c8777ce1795f81bf1a513396263fc1c9a30e5c *src/generated/prisma/commonInputTypes.ts
|
||||
8bcc37ae19ee9c55424b735ce5f3a0972b7e741958ef86b350c9a54dc2238a0d *src/generated/prisma/enums.ts
|
||||
d52628a3dc8285d9014e2fda489badce6c88604810e2975310ab6c40ec3c708d *src/generated/prisma/internal/class.ts
|
||||
91794eb9b3c395e57b96d18f2bae895149f7b70ddf0fda8b817e7aa9da17a950 *src/generated/prisma/internal/prismaNamespace.ts
|
||||
6645109792ed08507d79da319aaecc2752bffa6e8bd5a3364dccc5ae93e2f23c *src/generated/prisma/internal/prismaNamespaceBrowser.ts
|
||||
b8ffef1ad4428179847aafe66abee3ad5eb74a84fff46265532551b10ab2537d *src/generated/prisma/models.ts
|
||||
daecbb1c94a96d19da4accfdf81fe8f11fa73c543958016bc7d6060d454f3943 *src/generated/prisma/models/AnswerOption.ts
|
||||
87e18727cec17a341d32709bc0c1602c66e6c6dcc57672b9c9fbe38a6ce12801 *src/generated/prisma/models/ArcadeAttempt.ts
|
||||
0c9de62bdbefafe8b5ff5300d1d764cc73235a077f8e3b3e03e6144287610c9d *src/generated/prisma/models/ArcadePack.ts
|
||||
67b8ea01f0a866d14db7fdff3178a080e2879990129c5e08690463fb9940f48d *src/generated/prisma/models/AuthSecurity.ts
|
||||
6885465a57019e8cba28755d9c6abaa087e913d666d02d8912abf0790c41a77b *src/generated/prisma/models/Class.ts
|
||||
ea873177511c6f7354dc01ec0ad228d9f777bca2fbfb15d281d3f2ab8f817ebf *src/generated/prisma/models/Deck.ts
|
||||
1082ff29bec6e4f920c93d778032ffffa324c312b05efd6c5fa7ada1be6674f7 *src/generated/prisma/models/Flashcard.ts
|
||||
de94c2da405c08a0b690aeb1b0cdc03582c94ccddaf913f4c6d1b18d8c413b61 *src/generated/prisma/models/MaterialGroup.ts
|
||||
941998d2c098e9fe5cd0a888bac470190108238c936c1d7cfce2742ed1e40d8a *src/generated/prisma/models/Question.ts
|
||||
3d54ed93e516f4d773c200b166a84ab2c9c8b7b0d7f7ddfd61afc3ca057ee755 *src/generated/prisma/models/QuizAttempt.ts
|
||||
454f19f97a82b8f37fc5c5179e87afe6ea185a79810043471d55c0a8ca26f14a *src/generated/prisma/models/QuizSet.ts
|
||||
3b884aaaad311e8f22c1390cf24ebe69b74b52a97c213781d2a4cbb56288b8a7 *src/generated/prisma/models/Setting.ts
|
||||
1ccf32b2f56b1d35c9b7152d2e1817b4c1b7853549a1ff187e9fb7c29fa49f2a *src/generated/prisma/models/ShareLink.ts
|
||||
cba950d431c702afe67f3ac92d8397275d3aeeebbc59d357fd20dec2123e0ee2 *src/generated/prisma/models/SpacedRepetitionCardState.ts
|
||||
2ef43d1c5d7e2eb0d85080fb29703ae99c1704ba8f5200703e2bb8e7a617bf04 *src/generated/prisma/models/SpacedRepetitionSet.ts
|
||||
0bf9eab9f937ef2d621ccf0cea8e209eae9372a9b333dfe6b18267e563a6b2b0 *src/generated/prisma/models/SpacedRepetitionSetDeck.ts
|
||||
916bda8f95647f0dc3560bc6848c4a97d4c1f37fd5ed54e6736d9d7d45e08adc *src/generated/prisma/models/StudyActivity.ts
|
||||
36724157e76b49df49e7827eacbcb0ab8336917f35913777701d8a6830770500 *src/generated/prisma/models/StudyProgress.ts
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
// Read-only diagnostic: introspect the temp DB created by `prisma migrate deploy`
|
||||
// to check whether the migrated schema matches schema.prisma (drift check).
|
||||
// Usage: node audit-results/tmp/inspect-db.cjs <path-to-sqlite-db>
|
||||
const path = require("node:path");
|
||||
const Database = require("better-sqlite3");
|
||||
|
||||
const dbPath = process.argv[2];
|
||||
if (!dbPath) {
|
||||
console.error("usage: node inspect-db.cjs <db-path>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const db = new Database(path.resolve(dbPath), { readonly: true });
|
||||
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
.all()
|
||||
.map((r) => r.name);
|
||||
|
||||
console.log("=== TABLES ===");
|
||||
console.log(tables.join(", "));
|
||||
console.log("Has MaterialGroup table:", tables.includes("MaterialGroup"));
|
||||
|
||||
for (const table of ["Deck", "QuizSet", "ShareLink", "MaterialGroup"]) {
|
||||
if (tables.includes(table)) {
|
||||
const cols = db
|
||||
.prepare(`PRAGMA table_info('${table}')`)
|
||||
.all()
|
||||
.map((r) => r.name);
|
||||
console.log(`--- ${table} columns ---`);
|
||||
console.log(cols.join(", "));
|
||||
if (table === "Deck" || table === "QuizSet" || table === "ShareLink") {
|
||||
console.log(` -> has groupId:`, cols.includes("groupId"));
|
||||
}
|
||||
} else {
|
||||
console.log(`--- ${table}: MISSING TABLE ---`);
|
||||
}
|
||||
}
|
||||
|
||||
const indexes = db
|
||||
.prepare("SELECT name, tbl_name FROM sqlite_master WHERE type='index' ORDER BY name")
|
||||
.all()
|
||||
.map((r) => `${r.tbl_name}.${r.name}`);
|
||||
console.log("=== INDEXES (count) ===", indexes.length);
|
||||
|
||||
// Confirm migrations table
|
||||
const applied = db.prepare("SELECT migration_name FROM _prisma_migrations ORDER BY started_at").all();
|
||||
console.log("=== APPLIED MIGRATIONS ===");
|
||||
applied.forEach((m) => console.log(" -", m.migration_name));
|
||||
|
||||
db.close();
|
||||
console.log("DONE");
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
// Read-only diagnostic: execute the exact SQL shape the generated Prisma client
|
||||
// emits for deck.findMany() / materialGroup.findMany() against a DB built purely
|
||||
// from `prisma migrate deploy` (predev + Docker entrypoint flow).
|
||||
// Proves DBAUD-01: migrated DB is incompatible with the shipped client.
|
||||
const path = require("node:path");
|
||||
const Database = require("better-sqlite3");
|
||||
|
||||
const dbPath = path.resolve("audit-results/tmp/audit-migration.db");
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
|
||||
// 1. Shape of prisma.deck.findMany({ include: { class: true } }) — Prisma selects
|
||||
// every scalar field, including groupId (schema.prisma:33-34, generated client).
|
||||
const deckSql =
|
||||
'SELECT "Deck"."id", "Deck"."classId", "Deck"."name", "Deck"."description", "Deck"."sortOrder", "Deck"."createdAt", "Deck"."groupId" FROM "Deck"';
|
||||
try {
|
||||
db.prepare(deckSql).all();
|
||||
console.log("deck.findMany SQL: OK");
|
||||
} catch (e) {
|
||||
console.log("deck.findMany SQL: FAILED ->", e.message);
|
||||
}
|
||||
|
||||
// 2. Shape of prisma.materialGroup.findMany()
|
||||
const groupSql = 'SELECT "MaterialGroup"."id", "MaterialGroup"."classId", "MaterialGroup"."name", "MaterialGroup"."type", "MaterialGroup"."sortOrder", "MaterialGroup"."createdAt" FROM "MaterialGroup"';
|
||||
try {
|
||||
db.prepare(groupSql).all();
|
||||
console.log("materialGroup.findMany SQL: OK");
|
||||
} catch (e) {
|
||||
console.log("materialGroup.findMany SQL: FAILED ->", e.message);
|
||||
}
|
||||
|
||||
// 3. Shape of prisma.shareLink.findFirst() (share page validation)
|
||||
const shareSql = 'SELECT "ShareLink"."id", "ShareLink"."targetType", "ShareLink"."deckId", "ShareLink"."quizSetId", "ShareLink"."groupId", "ShareLink"."createdAt" FROM "ShareLink" LIMIT 1';
|
||||
try {
|
||||
db.prepare(shareSql).all();
|
||||
console.log("shareLink.findFirst SQL: OK");
|
||||
} catch (e) {
|
||||
console.log("shareLink.findFirst SQL: FAILED ->", e.message);
|
||||
}
|
||||
|
||||
db.close();
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
// Validates audit-results/FINDINGS.json parses and reports counts per severity.
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const file = path.resolve("audit-results/FINDINGS.json");
|
||||
const raw = fs.readFileSync(file, "utf8");
|
||||
const data = JSON.parse(raw); // throws if invalid
|
||||
|
||||
const findings = data.findings;
|
||||
const bySeverity = {};
|
||||
for (const f of findings) {
|
||||
bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1;
|
||||
}
|
||||
const byConfidence = {};
|
||||
for (const f of findings) {
|
||||
byConfidence[f.confidence] = (byConfidence[f.confidence] || 0) + 1;
|
||||
}
|
||||
|
||||
const ids = findings.map((f) => f.id);
|
||||
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
|
||||
if (dupes.length) throw new Error("duplicate ids: " + dupes.join(","));
|
||||
|
||||
console.log("FINDINGS.json: VALID JSON");
|
||||
console.log("total findings:", findings.length);
|
||||
console.log("by severity:", JSON.stringify(bySeverity));
|
||||
console.log("by confidence:", JSON.stringify(byConfidence));
|
||||
console.log("ids:", ids.join(", "));
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
import { performance } from "node:perf_hooks";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getActivitySummary } from "@/services/activityService";
|
||||
import { listClasses } from "@/services/classService";
|
||||
import { getStudyAvailabilityByClass } from "@/services/spacedRepetitionService";
|
||||
|
||||
const ACTIVITY_ROWS = 100_000;
|
||||
const CARD_ROWS = 10_000;
|
||||
const SETS = 5;
|
||||
const REQUEST_BUDGET_MS = 250;
|
||||
const NOW = new Date("2026-08-07T18:00:00.000Z");
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
function databasePath() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url?.startsWith("file:./")) {
|
||||
throw new Error("Benchmark requires the disposable Vitest database");
|
||||
}
|
||||
return path.resolve(process.cwd(), url.slice("file:".length));
|
||||
}
|
||||
|
||||
function seedRealApplicationSchema() {
|
||||
const database = new Database(databasePath());
|
||||
try {
|
||||
database.pragma("foreign_keys = ON");
|
||||
const insertActivity = database.prepare(
|
||||
'INSERT INTO "StudyActivity" ("id", "type", "occurredAt") VALUES (?, ?, ?)'
|
||||
);
|
||||
const insertDeck = database.prepare(
|
||||
'INSERT INTO "Deck" ("id", "classId", "name", "sortOrder") VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
const insertSet = database.prepare(
|
||||
'INSERT INTO "SpacedRepetitionSet" ("id", "classId", "name", "newCardsPerDay", "sortOrder", "updatedAt") VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
const insertMembership = database.prepare(
|
||||
'INSERT INTO "SpacedRepetitionSetDeck" ("setId", "deckId", "sortOrder") VALUES (?, ?, ?)'
|
||||
);
|
||||
const insertCard = database.prepare(
|
||||
'INSERT INTO "Flashcard" ("id", "deckId", "front", "back", "sortOrder") VALUES (?, ?, ?, ?, ?)'
|
||||
);
|
||||
const insertState = database.prepare(`
|
||||
INSERT INTO "SpacedRepetitionCardState"
|
||||
("id", "setId", "flashcardId", "due", "stability", "difficulty", "elapsedDays", "scheduledDays", "learningSteps", "reps", "lapses", "state", "firstReviewedAt", "lastReview", "createdAt", "updatedAt")
|
||||
VALUES (?, ?, ?, ?, 1, 5, 1, ?, 0, 1, 0, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
database.transaction(() => {
|
||||
database.prepare(
|
||||
'INSERT INTO "Class" ("id", "slug", "name", "sortOrder") VALUES (?, ?, ?, 0)'
|
||||
).run("benchmark-class", "benchmark-class", "Benchmark Class");
|
||||
|
||||
for (let index = 0; index < ACTIVITY_ROWS; index += 1) {
|
||||
const ageDays = index % 730;
|
||||
const type = index % 2 === 0 ? "FLASHCARD" : "QUIZ_QUESTION";
|
||||
insertActivity.run(
|
||||
`activity-${index}`,
|
||||
type,
|
||||
NOW.getTime() - ageDays * 86_400_000
|
||||
);
|
||||
}
|
||||
|
||||
for (let setIndex = 0; setIndex < SETS; setIndex += 1) {
|
||||
const deckId = `benchmark-deck-${setIndex}`;
|
||||
const setId = `benchmark-set-${setIndex}`;
|
||||
insertDeck.run(deckId, "benchmark-class", `Deck ${setIndex}`, setIndex);
|
||||
insertSet.run(setId, "benchmark-class", `Set ${setIndex}`, 30, setIndex, NOW.getTime());
|
||||
insertMembership.run(setId, deckId, 0);
|
||||
for (let cardIndex = setIndex; cardIndex < CARD_ROWS; cardIndex += SETS) {
|
||||
const cardId = `benchmark-card-${cardIndex}`;
|
||||
const due = NOW.getTime() + ((cardIndex % 200) - 100) * 60_000;
|
||||
const firstReviewed = NOW.getTime() - (cardIndex % 30) * 86_400_000;
|
||||
const scheduledDays = cardIndex % 4 === 0 ? 1 : 0;
|
||||
const state = cardIndex % 4;
|
||||
insertCard.run(cardId, deckId, `Front ${cardIndex}`, `Back ${cardIndex}`, cardIndex);
|
||||
insertState.run(
|
||||
`benchmark-state-${cardIndex}`,
|
||||
setId,
|
||||
cardId,
|
||||
due,
|
||||
scheduledDays,
|
||||
state,
|
||||
firstReviewed,
|
||||
firstReviewed,
|
||||
firstReviewed,
|
||||
NOW.getTime()
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function measure<T>(label: string, operation: () => Promise<T>) {
|
||||
const start = performance.now();
|
||||
const value = await operation();
|
||||
return {
|
||||
label,
|
||||
latencyMs: Number((performance.now() - start).toFixed(2)),
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
describe("remediation performance condition", () => {
|
||||
it("measures actual migrated services at the expected upper-use fixture", async () => {
|
||||
seedRealApplicationSchema();
|
||||
const activity = await measure("getActivitySummary", () => getActivitySummary(NOW));
|
||||
const availability = await measure("getStudyAvailabilityByClass", () =>
|
||||
getStudyAvailabilityByClass(NOW)
|
||||
);
|
||||
const classPolling = await measure("listClasses/navbar-poll", () => listClasses());
|
||||
const measurements = [activity, availability, classPolling].map((result) => ({
|
||||
label: result.label,
|
||||
latencyMs: result.latencyMs,
|
||||
}));
|
||||
|
||||
console.info(JSON.stringify({
|
||||
fixture: { activityRows: ACTIVITY_ROWS, cards: CARD_ROWS, sets: SETS },
|
||||
budgetMs: REQUEST_BUDGET_MS,
|
||||
prismaServiceOperations: { activity: 1, availability: 1, classPolling: 2 },
|
||||
measurements,
|
||||
activity: {
|
||||
displayedDays: activity.value.days.length,
|
||||
currentStreak: activity.value.currentStreak,
|
||||
},
|
||||
readyCards: availability.value["benchmark-class"]?.readyCards,
|
||||
}, null, 2));
|
||||
|
||||
expect(activity.value.days).toHaveLength(53 * 7);
|
||||
expect(activity.value.currentStreak).toBeGreaterThan(53 * 7);
|
||||
expect(availability.value["benchmark-class"]).toBeDefined();
|
||||
expect(classPolling.value).toHaveLength(1);
|
||||
for (const measurement of measurements) {
|
||||
expect(measurement.latencyMs, measurement.label).toBeLessThanOrEqual(
|
||||
REQUEST_BUDGET_MS
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
BIN
dev.db
Normal file
BIN
dev.db
Normal file
Binary file not shown.
|
|
@ -1,15 +1,12 @@
|
|||
services:
|
||||
study-app:
|
||||
build:
|
||||
context: .
|
||||
target: deps
|
||||
command: npm run dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- DATABASE_URL=file:/app/data/study.db
|
||||
- NODE_ENV=development
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
|
@ -4,13 +4,11 @@ services:
|
|||
container_name: study-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3726"
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- DATABASE_URL=file:/app/data/study.db
|
||||
- SESSION_SECRET=${SESSION_SECRET:?SESSION_SECRET must be set to at least 32 characters}
|
||||
- ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH:-}
|
||||
- ALLOW_INITIAL_SETUP=${ALLOW_INITIAL_SETUP:-false}
|
||||
- SECURE_COOKIES=${SECURE_COOKIES:-false}
|
||||
- SESSION_SECRET=${SESSION_SECRET}
|
||||
- ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH}
|
||||
- NODE_ENV=production
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
|
|
|
|||
|
|
@ -1,13 +1,4 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
DEFAULT_SECRET='dev-session-secret-change-in-production-must-be-32-chars'
|
||||
TRIMMED_SECRET=$(printf '%s' "${SESSION_SECRET:-}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
if [ -z "$TRIMMED_SECRET" ] || [ "${#TRIMMED_SECRET}" -lt 32 ] || [ "$TRIMMED_SECRET" = "$DEFAULT_SECRET" ]; then
|
||||
echo >&2 "SESSION_SECRET must be a non-default value of at least 32 characters"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
node scripts/migration-preflight.mjs --startup
|
||||
./node_modules/.bin/prisma migrate deploy
|
||||
npx prisma migrate deploy
|
||||
exec node server.js
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ const eslintConfig = defineConfig([
|
|||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
"audit-results/tmp/**",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
|
|
|||
1
out.css
Normal file
1
out.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
:root { --color-primary: #fff; }
|
||||
1459
package-lock.json
generated
1459
package-lock.json
generated
File diff suppressed because it is too large
Load diff
17
package.json
17
package.json
|
|
@ -3,16 +3,10 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"predev": "prisma migrate deploy",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"benchmark:remediation": "vitest run --config vitest.benchmark.config.ts",
|
||||
"db:preflight": "node scripts/migration-preflight.mjs",
|
||||
"db:backup": "node scripts/backup-database.mjs",
|
||||
"auth:reset": "node scripts/request-password-reset.mjs"
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
@ -24,25 +18,22 @@
|
|||
"better-sqlite3": "^12.11.1",
|
||||
"iron-session": "^8.0.4",
|
||||
"jsonrepair": "^3.14.1",
|
||||
"next": "16.3.0",
|
||||
"prisma": "^7.8.0",
|
||||
"next": "16.2.9",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"ts-fsrs": "^5.4.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/better-sqlite3": "^9.6.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.9",
|
||||
"prisma": "^7.8.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.10"
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "StudyActivity" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"type" TEXT NOT NULL,
|
||||
"occurredAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "StudyActivity_occurredAt_idx" ON "StudyActivity"("occurredAt");
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "SpacedRepetitionSet" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"newCardsPerDay" INTEGER NOT NULL DEFAULT 30,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "SpacedRepetitionSet_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SpacedRepetitionSetDeck" (
|
||||
"setId" TEXT NOT NULL,
|
||||
"deckId" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"addedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
PRIMARY KEY ("setId", "deckId"),
|
||||
CONSTRAINT "SpacedRepetitionSetDeck_setId_fkey" FOREIGN KEY ("setId") REFERENCES "SpacedRepetitionSet" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "SpacedRepetitionSetDeck_deckId_fkey" FOREIGN KEY ("deckId") REFERENCES "Deck" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SpacedRepetitionCardState" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"setId" TEXT NOT NULL,
|
||||
"flashcardId" TEXT NOT NULL,
|
||||
"due" DATETIME NOT NULL,
|
||||
"stability" REAL NOT NULL,
|
||||
"difficulty" REAL NOT NULL,
|
||||
"elapsedDays" INTEGER NOT NULL,
|
||||
"scheduledDays" INTEGER NOT NULL,
|
||||
"learningSteps" INTEGER NOT NULL DEFAULT 0,
|
||||
"reps" INTEGER NOT NULL,
|
||||
"lapses" INTEGER NOT NULL,
|
||||
"state" INTEGER NOT NULL,
|
||||
"firstReviewedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastReview" DATETIME,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "SpacedRepetitionCardState_setId_fkey" FOREIGN KEY ("setId") REFERENCES "SpacedRepetitionSet" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "SpacedRepetitionCardState_flashcardId_fkey" FOREIGN KEY ("flashcardId") REFERENCES "Flashcard" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SpacedRepetitionSet_classId_sortOrder_idx" ON "SpacedRepetitionSet"("classId", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SpacedRepetitionSetDeck_deckId_idx" ON "SpacedRepetitionSetDeck"("deckId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SpacedRepetitionCardState_setId_due_idx" ON "SpacedRepetitionCardState"("setId", "due");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SpacedRepetitionCardState_setId_firstReviewedAt_idx" ON "SpacedRepetitionCardState"("setId", "firstReviewedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SpacedRepetitionCardState_flashcardId_idx" ON "SpacedRepetitionCardState"("flashcardId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SpacedRepetitionCardState_setId_flashcardId_key" ON "SpacedRepetitionCardState"("setId", "flashcardId");
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "ArcadePack" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"gameType" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"schemaVersion" INTEGER NOT NULL,
|
||||
"sourceJson" TEXT NOT NULL,
|
||||
"normalizedJson" TEXT NOT NULL,
|
||||
"validationReportJson" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "ArcadePack_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ArcadeAttempt" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"arcadePackId" TEXT NOT NULL,
|
||||
"mode" TEXT NOT NULL,
|
||||
"score" INTEGER NOT NULL,
|
||||
"maxScore" INTEGER NOT NULL,
|
||||
"accuracy" REAL NOT NULL,
|
||||
"durationSeconds" INTEGER NOT NULL,
|
||||
"mistakes" INTEGER NOT NULL,
|
||||
"hintsUsed" INTEGER NOT NULL,
|
||||
"settingsJson" TEXT NOT NULL,
|
||||
"resultsJson" TEXT NOT NULL,
|
||||
"seed" TEXT NOT NULL,
|
||||
"completedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "ArcadeAttempt_arcadePackId_fkey" FOREIGN KEY ("arcadePackId") REFERENCES "ArcadePack" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ArcadePack_classId_gameType_sortOrder_idx" ON "ArcadePack"("classId", "gameType", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ArcadeAttempt_arcadePackId_completedAt_idx" ON "ArcadeAttempt"("arcadePackId", "completedAt");
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MaterialGroup" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "MaterialGroup_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
CREATE TABLE "new_Deck" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"groupId" TEXT,
|
||||
CONSTRAINT "Deck_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "Deck_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MaterialGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Deck" ("classId", "createdAt", "description", "id", "name", "sortOrder")
|
||||
SELECT "classId", "createdAt", "description", "id", "name", "sortOrder" FROM "Deck";
|
||||
DROP TABLE "Deck";
|
||||
ALTER TABLE "new_Deck" RENAME TO "Deck";
|
||||
|
||||
CREATE TABLE "new_QuizSet" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"groupId" TEXT,
|
||||
CONSTRAINT "QuizSet_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "QuizSet_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MaterialGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_QuizSet" ("classId", "createdAt", "description", "id", "name", "sortOrder")
|
||||
SELECT "classId", "createdAt", "description", "id", "name", "sortOrder" FROM "QuizSet";
|
||||
DROP TABLE "QuizSet";
|
||||
ALTER TABLE "new_QuizSet" RENAME TO "QuizSet";
|
||||
|
||||
CREATE TABLE "new_ShareLink" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"targetType" TEXT NOT NULL,
|
||||
"deckId" TEXT,
|
||||
"quizSetId" TEXT,
|
||||
"groupId" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "ShareLink_deckId_fkey" FOREIGN KEY ("deckId") REFERENCES "Deck" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "ShareLink_quizSetId_fkey" FOREIGN KEY ("quizSetId") REFERENCES "QuizSet" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "ShareLink_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MaterialGroup" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_ShareLink" ("createdAt", "deckId", "id", "quizSetId", "targetType")
|
||||
SELECT "createdAt", "deckId", "id", "quizSetId", "targetType" FROM "ShareLink";
|
||||
DROP TABLE "ShareLink";
|
||||
ALTER TABLE "new_ShareLink" RENAME TO "ShareLink";
|
||||
CREATE UNIQUE INDEX "ShareLink_deckId_key" ON "ShareLink"("deckId");
|
||||
CREATE UNIQUE INDEX "ShareLink_quizSetId_key" ON "ShareLink"("quizSetId");
|
||||
CREATE UNIQUE INDEX "ShareLink_groupId_key" ON "ShareLink"("groupId");
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "QuizAttempt" ADD COLUMN "reviewSnapshotJson" TEXT;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "StudyProgress" ADD COLUMN "sessionId" TEXT NOT NULL DEFAULT 'legacy';
|
||||
ALTER TABLE "StudyProgress" ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 0;
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
-- Remove Arcade-generated activity and saved instruction settings.
|
||||
DELETE FROM "StudyActivity"
|
||||
WHERE "type" IN ('ARCADE_GROUP', 'ARCADE_WORD');
|
||||
|
||||
DELETE FROM "Setting"
|
||||
WHERE "key" IN ('llmInstructionsConnections', 'llmInstructionsCrossword');
|
||||
|
||||
-- Drop the dependent table before its parent while SQLite foreign keys are enabled.
|
||||
DROP TABLE IF EXISTS "ArcadeAttempt";
|
||||
DROP TABLE IF EXISTS "ArcadePack";
|
||||
|
|
@ -17,7 +17,6 @@ model Class {
|
|||
decks Deck[]
|
||||
quizSets QuizSet[]
|
||||
materialGroups MaterialGroup[]
|
||||
spacedRepetitionSets SpacedRepetitionSet[]
|
||||
}
|
||||
|
||||
model Deck {
|
||||
|
|
@ -34,7 +33,6 @@ model Deck {
|
|||
cards Flashcard[]
|
||||
shareLink ShareLink?
|
||||
progress StudyProgress[]
|
||||
spacedRepetitionMemberships SpacedRepetitionSetDeck[]
|
||||
}
|
||||
|
||||
model Flashcard {
|
||||
|
|
@ -45,7 +43,6 @@ model Flashcard {
|
|||
sortOrder Int @default(0)
|
||||
|
||||
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
|
||||
spacedRepetitionStates SpacedRepetitionCardState[]
|
||||
}
|
||||
|
||||
model QuizSet {
|
||||
|
|
@ -98,8 +95,6 @@ model StudyProgress {
|
|||
orderJson String // JSON array of card/question ids
|
||||
answersJson String? // in-progress quiz answers
|
||||
cardResultsJson String? // per-card grades for decks
|
||||
sessionId String @default("legacy")
|
||||
revision Int @default(0)
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
deck Deck? @relation(fields: [deckId], references: [id], onDelete: Cascade)
|
||||
|
|
@ -115,7 +110,6 @@ model QuizAttempt {
|
|||
score Float
|
||||
maxScore Int
|
||||
answersJson String
|
||||
reviewSnapshotJson String?
|
||||
isPartialRetake Boolean @default(false)
|
||||
completedAt DateTime @default(now())
|
||||
|
||||
|
|
@ -147,14 +141,6 @@ model Setting {
|
|||
value String
|
||||
}
|
||||
|
||||
model StudyActivity {
|
||||
id String @id @default(uuid())
|
||||
type String // "FLASHCARD" | "QUIZ_QUESTION"
|
||||
occurredAt DateTime @default(now())
|
||||
|
||||
@@index([occurredAt])
|
||||
}
|
||||
|
||||
model MaterialGroup {
|
||||
id String @id @default(uuid())
|
||||
classId String
|
||||
|
|
@ -168,60 +154,3 @@ model MaterialGroup {
|
|||
quizSets QuizSet[]
|
||||
shareLink ShareLink?
|
||||
}
|
||||
|
||||
model SpacedRepetitionSet {
|
||||
id String @id @default(uuid())
|
||||
classId String
|
||||
name String
|
||||
description String?
|
||||
newCardsPerDay Int @default(30)
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
decks SpacedRepetitionSetDeck[]
|
||||
cardStates SpacedRepetitionCardState[]
|
||||
|
||||
@@index([classId, sortOrder])
|
||||
}
|
||||
|
||||
model SpacedRepetitionSetDeck {
|
||||
setId String
|
||||
deckId String
|
||||
sortOrder Int @default(0)
|
||||
addedAt DateTime @default(now())
|
||||
|
||||
set SpacedRepetitionSet @relation(fields: [setId], references: [id], onDelete: Cascade)
|
||||
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([setId, deckId])
|
||||
@@index([deckId])
|
||||
}
|
||||
|
||||
model SpacedRepetitionCardState {
|
||||
id String @id @default(uuid())
|
||||
setId String
|
||||
flashcardId String
|
||||
due DateTime
|
||||
stability Float
|
||||
difficulty Float
|
||||
elapsedDays Int
|
||||
scheduledDays Int
|
||||
learningSteps Int @default(0)
|
||||
reps Int
|
||||
lapses Int
|
||||
state Int
|
||||
firstReviewedAt DateTime @default(now())
|
||||
lastReview DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
set SpacedRepetitionSet @relation(fields: [setId], references: [id], onDelete: Cascade)
|
||||
flashcard Flashcard @relation(fields: [flashcardId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([setId, flashcardId])
|
||||
@@index([setId, due])
|
||||
@@index([setId, firstReviewedAt])
|
||||
@@index([flashcardId])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import Database from "better-sqlite3";
|
||||
import { databasePathFromUrl } from "./databasePath.mjs";
|
||||
|
||||
function argument(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
export async function backupDatabase(sourcePath, outputPath) {
|
||||
const source = path.resolve(sourcePath);
|
||||
const output = path.resolve(outputPath);
|
||||
if (!existsSync(source)) throw new Error(`Source database does not exist: ${source}`);
|
||||
if (existsSync(output)) throw new Error(`Backup destination already exists: ${output}`);
|
||||
if (source === output) throw new Error("Backup destination must differ from source");
|
||||
|
||||
const database = new Database(source, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
await database.backup(output);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
const backup = new Database(output, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const integrity = backup.pragma("integrity_check", { simple: true });
|
||||
if (integrity !== "ok") throw new Error(`Backup integrity check failed: ${integrity}`);
|
||||
} finally {
|
||||
backup.close();
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const source = argument("--source") ?? databasePathFromUrl(process.env.DATABASE_URL);
|
||||
const output = argument("--output");
|
||||
if (!output) throw new Error("Usage: npm run db:backup -- --output <new-backup.db>");
|
||||
const completedPath = await backupDatabase(source, output);
|
||||
console.info(`Verified SQLite backup created: ${completedPath}`);
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import path from "node:path";
|
||||
|
||||
export function databasePathFromUrl(databaseUrl, cwd = process.cwd()) {
|
||||
if (!databaseUrl?.startsWith("file:")) {
|
||||
throw new Error("DATABASE_URL must be a file: SQLite URL");
|
||||
}
|
||||
const rawPath = databaseUrl.slice("file:".length).split("?")[0];
|
||||
if (!rawPath) throw new Error("DATABASE_URL does not contain a database path");
|
||||
return path.resolve(cwd, rawPath);
|
||||
}
|
||||
|
|
@ -1,358 +0,0 @@
|
|||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import Database from "better-sqlite3";
|
||||
import { databasePathFromUrl } from "./databasePath.mjs";
|
||||
|
||||
export const GROUP_MIGRATION = "20260807090000_add_material_groups";
|
||||
|
||||
function tableExists(database, table) {
|
||||
return Boolean(
|
||||
database
|
||||
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(table)
|
||||
);
|
||||
}
|
||||
|
||||
function hasColumn(database, table, column) {
|
||||
if (!tableExists(database, table)) return false;
|
||||
return database.pragma(`table_info(${table})`).some((item) => item.name === column);
|
||||
}
|
||||
|
||||
function hasForeignKey(database, table, from, target, onDelete) {
|
||||
return database
|
||||
.pragma(`foreign_key_list(${table})`)
|
||||
.some(
|
||||
(item) =>
|
||||
item.from === from && item.table === target && item.on_delete === onDelete
|
||||
);
|
||||
}
|
||||
|
||||
function hasUniqueIndex(database, table, indexName, column) {
|
||||
const index = database
|
||||
.pragma(`index_list(${table})`)
|
||||
.find((item) => item.name === indexName && item.unique === 1);
|
||||
if (!index) return false;
|
||||
const columns = database.pragma(`index_info(${indexName})`);
|
||||
return columns.length === 1 && columns[0].name === column;
|
||||
}
|
||||
|
||||
function schemaSnapshot(database, includeMigrationTable = false) {
|
||||
const tables = database
|
||||
.prepare(
|
||||
`SELECT name FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
${includeMigrationTable ? "" : "AND name <> '_prisma_migrations'"}
|
||||
ORDER BY name`
|
||||
)
|
||||
.all()
|
||||
.map((row) => row.name);
|
||||
|
||||
return tables.map((table) => ({
|
||||
table,
|
||||
columns: database.pragma(`table_info(${JSON.stringify(table)})`).map((column) => ({
|
||||
name: column.name,
|
||||
type: column.type,
|
||||
notnull: column.notnull,
|
||||
defaultValue: column.dflt_value,
|
||||
primaryKey: column.pk,
|
||||
})),
|
||||
foreignKeys: database
|
||||
.pragma(`foreign_key_list(${JSON.stringify(table)})`)
|
||||
.map((foreignKey) => ({
|
||||
id: foreignKey.id,
|
||||
sequence: foreignKey.seq,
|
||||
target: foreignKey.table,
|
||||
from: foreignKey.from,
|
||||
to: foreignKey.to,
|
||||
onUpdate: foreignKey.on_update,
|
||||
onDelete: foreignKey.on_delete,
|
||||
match: foreignKey.match,
|
||||
}))
|
||||
.sort((left, right) => left.id - right.id || left.sequence - right.sequence),
|
||||
indexes: database
|
||||
.pragma(`index_list(${JSON.stringify(table)})`)
|
||||
.filter((index) => !index.name.startsWith("sqlite_autoindex_"))
|
||||
.map((index) => ({
|
||||
name: index.name,
|
||||
unique: index.unique,
|
||||
partial: index.partial,
|
||||
columns: database
|
||||
.pragma(`index_info(${JSON.stringify(index.name)})`)
|
||||
.map((column) => column.name),
|
||||
}))
|
||||
.sort((left, right) => left.name.localeCompare(right.name)),
|
||||
}));
|
||||
}
|
||||
|
||||
function expectedSchemaSnapshot(migrationNames) {
|
||||
const database = new Database(":memory:");
|
||||
try {
|
||||
database.pragma("foreign_keys = ON");
|
||||
for (const migrationName of migrationNames) {
|
||||
database.exec(
|
||||
readFileSync(
|
||||
path.join(
|
||||
process.cwd(),
|
||||
"prisma",
|
||||
"migrations",
|
||||
migrationName,
|
||||
"migration.sql"
|
||||
),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
}
|
||||
return schemaSnapshot(database);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function inspectMigrationState(databasePath) {
|
||||
if (!existsSync(databasePath)) {
|
||||
throw new Error(`Database does not exist: ${databasePath}`);
|
||||
}
|
||||
const database = new Database(databasePath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const migrationRecords = tableExists(database, "_prisma_migrations")
|
||||
? database
|
||||
.prepare(
|
||||
'SELECT "migration_name", "finished_at", "rolled_back_at" FROM "_prisma_migrations"'
|
||||
)
|
||||
.all()
|
||||
: [];
|
||||
const activeRecords = migrationRecords.filter((row) => row.rolled_back_at === null);
|
||||
const migrationNames = activeRecords
|
||||
.filter((row) => row.finished_at !== null)
|
||||
.map((row) => row.migration_name);
|
||||
const failedMigrationNames = activeRecords
|
||||
.filter((row) => row.finished_at === null)
|
||||
.map((row) => row.migration_name);
|
||||
const committedMigrations = readdirSync(
|
||||
path.join(process.cwd(), "prisma", "migrations"),
|
||||
{ withFileTypes: true }
|
||||
)
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
const priorMigrations = committedMigrations.filter(
|
||||
(name) => name < GROUP_MIGRATION
|
||||
);
|
||||
const groupMigrationIndex = committedMigrations.indexOf(GROUP_MIGRATION);
|
||||
|
||||
const artifacts = {
|
||||
materialGroup: tableExists(database, "MaterialGroup"),
|
||||
deckColumn: hasColumn(database, "Deck", "groupId"),
|
||||
quizColumn: hasColumn(database, "QuizSet", "groupId"),
|
||||
shareColumn: hasColumn(database, "ShareLink", "groupId"),
|
||||
deckForeignKey: hasForeignKey(
|
||||
database,
|
||||
"Deck",
|
||||
"groupId",
|
||||
"MaterialGroup",
|
||||
"SET NULL"
|
||||
),
|
||||
quizForeignKey: hasForeignKey(
|
||||
database,
|
||||
"QuizSet",
|
||||
"groupId",
|
||||
"MaterialGroup",
|
||||
"SET NULL"
|
||||
),
|
||||
shareForeignKey: hasForeignKey(
|
||||
database,
|
||||
"ShareLink",
|
||||
"groupId",
|
||||
"MaterialGroup",
|
||||
"CASCADE"
|
||||
),
|
||||
shareUnique: hasUniqueIndex(
|
||||
database,
|
||||
"ShareLink",
|
||||
"ShareLink_groupId_key",
|
||||
"groupId"
|
||||
),
|
||||
};
|
||||
const priorHistoryComplete = priorMigrations.every((name) =>
|
||||
migrationNames.includes(name)
|
||||
);
|
||||
const knownMigrationNames = new Set(committedMigrations);
|
||||
const historyHasUnknown = activeRecords.some(
|
||||
(row) => !knownMigrationNames.has(row.migration_name)
|
||||
);
|
||||
const actualSchema = schemaSnapshot(database);
|
||||
const matches = (expected) =>
|
||||
JSON.stringify(actualSchema) === JSON.stringify(expected);
|
||||
const preGroupSchemaExact = matches(expectedSchemaSnapshot(priorMigrations));
|
||||
const adoptedGroupSchemaExact = matches(
|
||||
expectedSchemaSnapshot([...priorMigrations, GROUP_MIGRATION])
|
||||
);
|
||||
let historyPrefixLength = 0;
|
||||
while (
|
||||
historyPrefixLength < committedMigrations.length &&
|
||||
migrationNames.includes(committedMigrations[historyPrefixLength])
|
||||
) {
|
||||
historyPrefixLength += 1;
|
||||
}
|
||||
const historyIsExactPrefix =
|
||||
!historyHasUnknown &&
|
||||
migrationNames.length === historyPrefixLength &&
|
||||
migrationNames.every((name) =>
|
||||
committedMigrations.slice(0, historyPrefixLength).includes(name)
|
||||
);
|
||||
const trackedSchemaExact =
|
||||
historyIsExactPrefix &&
|
||||
matches(expectedSchemaSnapshot(committedMigrations.slice(0, historyPrefixLength)));
|
||||
let schemaPrefixLength = -1;
|
||||
for (let index = 0; index <= committedMigrations.length; index += 1) {
|
||||
if (matches(expectedSchemaSnapshot(committedMigrations.slice(0, index)))) {
|
||||
schemaPrefixLength = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const migrationsToAdopt =
|
||||
schemaPrefixLength > historyPrefixLength
|
||||
? committedMigrations.slice(historyPrefixLength, schemaPrefixLength)
|
||||
: [];
|
||||
const failuresAreAdoptable = failedMigrationNames.every((name) =>
|
||||
migrationsToAdopt.includes(name)
|
||||
);
|
||||
|
||||
let classification = "CONFLICT";
|
||||
if (
|
||||
historyIsExactPrefix &&
|
||||
failedMigrationNames.length === 0 &&
|
||||
schemaPrefixLength === historyPrefixLength
|
||||
) {
|
||||
classification =
|
||||
historyPrefixLength === committedMigrations.length ? "CURRENT" : "APPLY";
|
||||
} else if (
|
||||
historyIsExactPrefix &&
|
||||
priorHistoryComplete &&
|
||||
historyPrefixLength >= groupMigrationIndex &&
|
||||
schemaPrefixLength > historyPrefixLength &&
|
||||
failuresAreAdoptable
|
||||
) {
|
||||
classification = "ADOPT";
|
||||
}
|
||||
|
||||
return {
|
||||
classification,
|
||||
artifacts,
|
||||
migrationNames,
|
||||
failedMigrationNames,
|
||||
migrationsToAdopt,
|
||||
priorHistoryComplete,
|
||||
schemaMigrationPrefix:
|
||||
schemaPrefixLength >= 0
|
||||
? committedMigrations[schemaPrefixLength - 1] ?? null
|
||||
: null,
|
||||
exactSchema: {
|
||||
preGroup: preGroupSchemaExact,
|
||||
adoptedGroup: adoptedGroupSchemaExact,
|
||||
tracked: trackedSchemaExact,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
function verifyBackup(backupPath, sourcePath) {
|
||||
if (!backupPath || !existsSync(backupPath)) {
|
||||
throw new Error("--resolve requires an existing verified --backup file");
|
||||
}
|
||||
if (path.resolve(backupPath) === path.resolve(sourcePath)) {
|
||||
throw new Error("--backup must be a separate database file, not the source database");
|
||||
}
|
||||
const backup = new Database(backupPath, { readonly: true, fileMustExist: true });
|
||||
const source = new Database(sourcePath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
if (backup.pragma("integrity_check", { simple: true }) !== "ok") {
|
||||
throw new Error("Backup failed SQLite integrity_check");
|
||||
}
|
||||
if (
|
||||
JSON.stringify(schemaSnapshot(backup, true)) !==
|
||||
JSON.stringify(schemaSnapshot(source, true))
|
||||
) {
|
||||
throw new Error("Backup schema does not match the source database");
|
||||
}
|
||||
const sourceCounts = Object.fromEntries(
|
||||
schemaSnapshot(source, true).map(({ table }) => [
|
||||
table,
|
||||
source.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count,
|
||||
])
|
||||
);
|
||||
const backupCounts = Object.fromEntries(
|
||||
schemaSnapshot(backup, true).map(({ table }) => [
|
||||
table,
|
||||
backup.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count,
|
||||
])
|
||||
);
|
||||
if (JSON.stringify(sourceCounts) !== JSON.stringify(backupCounts)) {
|
||||
throw new Error("Backup row counts do not match the source database");
|
||||
}
|
||||
} finally {
|
||||
backup.close();
|
||||
source.close();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMigration(databaseUrl, migrationName) {
|
||||
const cli = path.join(process.cwd(), "node_modules", "prisma", "build", "index.js");
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[cli, "migrate", "resolve", "--applied", migrationName],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, DATABASE_URL: databaseUrl },
|
||||
encoding: "utf8",
|
||||
}
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Prisma migration resolve failed:\n${result.stdout}\n${result.stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const databasePath = databasePathFromUrl(databaseUrl);
|
||||
const startup = process.argv.includes("--startup");
|
||||
const state =
|
||||
startup && !existsSync(databasePath)
|
||||
? {
|
||||
classification: "EMPTY",
|
||||
migrationNames: [],
|
||||
failedMigrationNames: [],
|
||||
migrationsToAdopt: [],
|
||||
}
|
||||
: inspectMigrationState(databasePath);
|
||||
console.info(JSON.stringify(state, null, 2));
|
||||
|
||||
if (process.argv.includes("--resolve")) {
|
||||
if (state.classification !== "ADOPT") {
|
||||
throw new Error(`Refusing adoption for ${state.classification} database state`);
|
||||
}
|
||||
const backupIndex = process.argv.indexOf("--backup");
|
||||
verifyBackup(
|
||||
backupIndex >= 0 ? process.argv[backupIndex + 1] : undefined,
|
||||
databasePath
|
||||
);
|
||||
for (const migrationName of state.migrationsToAdopt) {
|
||||
resolveMigration(databaseUrl, migrationName);
|
||||
}
|
||||
console.info(
|
||||
`Marked ${state.migrationsToAdopt.join(", ")} applied after exact-schema preflight.`
|
||||
);
|
||||
} else if (startup && state.classification === "ADOPT") {
|
||||
console.error(
|
||||
"Database schema requires explicit migration adoption. Stop, create and verify a backup, then run db:preflight -- --resolve --backup <backup-path>."
|
||||
);
|
||||
process.exitCode = 3;
|
||||
} else if (state.classification === "CONFLICT") {
|
||||
process.exitCode = 2;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import Database from "better-sqlite3";
|
||||
import { databasePathFromUrl } from "./databasePath.mjs";
|
||||
|
||||
const PASSWORD_HASH_KEY = "admin_password_hash";
|
||||
const RESET_TOKEN_KEY = "admin_password_reset";
|
||||
const RESET_TOKEN_LIFETIME_MS = 15 * 60 * 1000;
|
||||
|
||||
function activeRecord(value) {
|
||||
try {
|
||||
const record = JSON.parse(value);
|
||||
return new Date(record.expiresAt).getTime() > Date.now();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createResetToken(databasePath) {
|
||||
if (!existsSync(databasePath)) throw new Error(`Database does not exist: ${databasePath}`);
|
||||
const database = new Database(databasePath, { fileMustExist: true });
|
||||
try {
|
||||
if (!database.prepare('SELECT 1 FROM "Setting" WHERE "key" = ?').get(PASSWORD_HASH_KEY)) {
|
||||
throw new Error("Password recovery is unavailable before initial setup");
|
||||
}
|
||||
const existing = database
|
||||
.prepare('SELECT "value" FROM "Setting" WHERE "key" = ?')
|
||||
.get(RESET_TOKEN_KEY);
|
||||
if (existing && activeRecord(existing.value)) {
|
||||
throw new Error("An unexpired reset token already exists; it was not replaced");
|
||||
}
|
||||
|
||||
const token = randomBytes(24).toString("base64url");
|
||||
const expiresAt = new Date(Date.now() + RESET_TOKEN_LIFETIME_MS).toISOString();
|
||||
const value = JSON.stringify({
|
||||
digest: createHash("sha256").update(token, "utf8").digest("hex"),
|
||||
nonce: randomUUID(),
|
||||
expiresAt,
|
||||
});
|
||||
database
|
||||
.prepare(
|
||||
'INSERT INTO "Setting" ("key", "value") VALUES (?, ?) ON CONFLICT("key") DO UPDATE SET "value" = excluded."value"'
|
||||
)
|
||||
.run(RESET_TOKEN_KEY, value);
|
||||
return { token, expiresAt };
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const result = createResetToken(databasePathFromUrl(process.env.DATABASE_URL));
|
||||
console.info(`Password reset token: ${result.token}`);
|
||||
console.info(`Expires: ${result.expiresAt}`);
|
||||
}
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import { backupDatabase } from "./backup-database.mjs";
|
||||
|
||||
const root = path.join(process.cwd(), ".test-databases");
|
||||
const directory = path.join(root, `backup-restore-${randomUUID()}`);
|
||||
if (!path.resolve(directory).startsWith(`${path.resolve(root)}${path.sep}`)) {
|
||||
throw new Error("Unsafe disposable backup verification path");
|
||||
}
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const sourcePath = path.join(directory, "source.test.db");
|
||||
const backupPath = path.join(directory, "backup.test.db");
|
||||
const restoredPath = path.join(directory, "restored.test.db");
|
||||
let source;
|
||||
|
||||
function rowCounts(database) {
|
||||
const tables = database
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||
)
|
||||
.all()
|
||||
.map((row) => row.name);
|
||||
return Object.fromEntries(
|
||||
tables.map((table) => [
|
||||
table,
|
||||
database.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
source = new Database(sourcePath);
|
||||
source.pragma("foreign_keys = ON");
|
||||
source.pragma("journal_mode = WAL");
|
||||
source.pragma("wal_autocheckpoint = 0");
|
||||
const migrations = readdirSync(path.join(process.cwd(), "prisma", "migrations"), {
|
||||
withFileTypes: true,
|
||||
})
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
for (const migration of migrations) {
|
||||
source.exec(
|
||||
readFileSync(
|
||||
path.join(process.cwd(), "prisma", "migrations", migration, "migration.sql"),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
}
|
||||
source.transaction(() => {
|
||||
source
|
||||
.prepare('INSERT INTO "Class" ("id", "name", "slug", "sortOrder") VALUES (?, ?, ?, ?)')
|
||||
.run("backup-class", "Representative content", "representative-content", 0);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "MaterialGroup" ("id", "classId", "name", "type", "sortOrder") VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run("backup-group", "backup-class", "Backup group", "DECK", 0);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "Deck" ("id", "classId", "groupId", "name", "description", "sortOrder") VALUES (?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run(
|
||||
"backup-deck",
|
||||
"backup-class",
|
||||
"backup-group",
|
||||
"Backup deck",
|
||||
"Restore drill content",
|
||||
0
|
||||
);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "Flashcard" ("id", "deckId", "front", "back", "sortOrder") VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run("backup-card", "backup-deck", "Representative front", "Representative back", 0);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "QuizSet" ("id", "classId", "name", "description", "sortOrder") VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run("backup-quiz", "backup-class", "Backup quiz", "Quiz relationship", 0);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "Question" ("id", "quizSetId", "type", "prompt", "rationale", "category", "sortOrder") VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run(
|
||||
"backup-question",
|
||||
"backup-quiz",
|
||||
"MULTIPLE_CHOICE",
|
||||
"Representative prompt",
|
||||
"Representative rationale",
|
||||
"backup",
|
||||
0
|
||||
);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "AnswerOption" ("id", "questionId", "text", "isCorrect", "sortOrder") VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run("backup-option", "backup-question", "Correct option", 1, 0);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "SpacedRepetitionSet" ("id", "classId", "name", "sortOrder", "updatedAt") VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run("backup-srs", "backup-class", "Backup SRS", 0, Date.now());
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "SpacedRepetitionSetDeck" ("setId", "deckId", "sortOrder") VALUES (?, ?, ?)'
|
||||
)
|
||||
.run("backup-srs", "backup-deck", 0);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "ShareLink" ("id", "targetType", "groupId") VALUES (?, ?, ?)'
|
||||
)
|
||||
.run("backup-share", "GROUP", "backup-group");
|
||||
})();
|
||||
|
||||
const walPath = `${sourcePath}-wal`;
|
||||
if (!existsSync(walPath) || statSync(walPath).size === 0) {
|
||||
throw new Error("Backup drill did not create an active WAL database");
|
||||
}
|
||||
const sourceWalBytes = statSync(walPath).size;
|
||||
const sourceCounts = rowCounts(source);
|
||||
await backupDatabase(sourcePath, backupPath);
|
||||
copyFileSync(backupPath, restoredPath);
|
||||
source.close();
|
||||
source = undefined;
|
||||
|
||||
const restored = new Database(restoredPath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const integrity = restored.pragma("integrity_check", { simple: true });
|
||||
const foreignKeyErrors = restored.pragma("foreign_key_check");
|
||||
const restoredCounts = rowCounts(restored);
|
||||
const representative = restored
|
||||
.prepare(`
|
||||
SELECT c.name AS className, g.name AS groupName, d.description,
|
||||
f.front, s.name AS setName, sl.targetType
|
||||
FROM "Class" c
|
||||
JOIN "MaterialGroup" g ON g.classId = c.id
|
||||
JOIN "Deck" d ON d.groupId = g.id
|
||||
JOIN "Flashcard" f ON f.deckId = d.id
|
||||
JOIN "SpacedRepetitionSetDeck" sd ON sd.deckId = d.id
|
||||
JOIN "SpacedRepetitionSet" s ON s.id = sd.setId
|
||||
JOIN "ShareLink" sl ON sl.groupId = g.id
|
||||
WHERE d.id = ?
|
||||
`)
|
||||
.get("backup-deck");
|
||||
const quizRelationship = restored
|
||||
.prepare(`
|
||||
SELECT q.name AS quizName, question.prompt, option.text, option.isCorrect
|
||||
FROM "QuizSet" q
|
||||
JOIN "Question" question ON question.quizSetId = q.id
|
||||
JOIN "AnswerOption" option ON option.questionId = question.id
|
||||
WHERE q.id = ?
|
||||
`)
|
||||
.get("backup-quiz");
|
||||
if (
|
||||
integrity !== "ok" ||
|
||||
foreignKeyErrors.length !== 0 ||
|
||||
JSON.stringify(restoredCounts) !== JSON.stringify(sourceCounts) ||
|
||||
representative?.description !== "Restore drill content" ||
|
||||
representative?.setName !== "Backup SRS" ||
|
||||
representative?.targetType !== "GROUP" ||
|
||||
quizRelationship?.isCorrect !== 1
|
||||
) {
|
||||
throw new Error("Restored database did not preserve integrity, counts, content, and relationships");
|
||||
}
|
||||
console.info(
|
||||
JSON.stringify(
|
||||
{
|
||||
method: "better-sqlite3 online backup while the WAL source remained open",
|
||||
sourceWalBytes,
|
||||
integrity,
|
||||
foreignKeyErrors,
|
||||
counts: restoredCounts,
|
||||
representative,
|
||||
quizRelationship,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
restored.close();
|
||||
}
|
||||
} finally {
|
||||
source?.close();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const root = path.join(process.cwd(), ".test-databases");
|
||||
const directory = path.join(root, `http-smoke-${randomUUID()}`);
|
||||
if (!path.resolve(directory).startsWith(`${path.resolve(root)}${path.sep}`)) {
|
||||
throw new Error("Unsafe disposable HTTP smoke path");
|
||||
}
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const databasePath = path.join(directory, "http-smoke.test.db");
|
||||
const database = new Database(databasePath);
|
||||
try {
|
||||
database.pragma("foreign_keys = ON");
|
||||
for (const migration of readdirSync(path.join(process.cwd(), "prisma", "migrations"), { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort()) {
|
||||
database.exec(readFileSync(path.join(process.cwd(), "prisma", "migrations", migration, "migration.sql"), "utf8"));
|
||||
}
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
const port = 3789;
|
||||
const server = spawn(
|
||||
process.execPath,
|
||||
[path.join(process.cwd(), "node_modules", "next", "dist", "bin", "next"), "start", "-p", String(port)],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "production",
|
||||
DATABASE_URL: `file:${databasePath.replaceAll("\\", "/")}`,
|
||||
SESSION_SECRET: "http-smoke-secret-0123456789abcdef0123456789",
|
||||
ALLOW_INITIAL_SETUP: "true",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
}
|
||||
);
|
||||
let output = "";
|
||||
server.stdout.on("data", (chunk) => { output += chunk.toString(); });
|
||||
server.stderr.on("data", (chunk) => { output += chunk.toString(); });
|
||||
|
||||
try {
|
||||
let health;
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
try {
|
||||
health = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||
if (health.ok) break;
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
if (!health?.ok) throw new Error(`Health check did not become ready.\n${output}`);
|
||||
const login = await fetch(`http://127.0.0.1:${port}/login`);
|
||||
const setup = await fetch(`http://127.0.0.1:${port}/api/auth/setup-status`);
|
||||
const setupBody = await setup.json();
|
||||
const protectedDot = await fetch(`http://127.0.0.1:${port}/api/decks/file.json`, { redirect: "manual" });
|
||||
if (!login.ok || !setup.ok || setupBody.setupRequired !== true || setupBody.setupAllowed !== true || protectedDot.status !== 307) {
|
||||
throw new Error("HTTP smoke responses did not match the production contract");
|
||||
}
|
||||
console.info(JSON.stringify({
|
||||
health: health.status,
|
||||
login: login.status,
|
||||
setup: setupBody,
|
||||
protectedDot: { status: protectedDot.status, location: protectedDot.headers.get("location") },
|
||||
}, null, 2));
|
||||
if (process.argv.includes("--stay")) {
|
||||
console.info(`Browser smoke server ready at http://127.0.0.1:${port}/login`);
|
||||
await new Promise(() => {});
|
||||
}
|
||||
} finally {
|
||||
server.kill();
|
||||
await new Promise((resolve) => {
|
||||
if (server.exitCode !== null) return resolve();
|
||||
server.once("exit", resolve);
|
||||
setTimeout(resolve, 2_000);
|
||||
});
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -25,8 +25,6 @@ interface DeckData {
|
|||
currentIndex: number;
|
||||
orderJson: string;
|
||||
cardResultsJson: string | null;
|
||||
sessionId: string;
|
||||
revision: number;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
}
|
||||
|
|
@ -44,24 +42,19 @@ export default function DeckStudyPage() {
|
|||
async function handleRestart() {
|
||||
if (!deck) return;
|
||||
|
||||
const responses = await Promise.all(
|
||||
deck.progress.map((progress) =>
|
||||
// Clear progress in database for both modes
|
||||
await Promise.all([
|
||||
fetch("/api/progress", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contentType: "DECK",
|
||||
contentId: deck.id,
|
||||
mode: progress.mode,
|
||||
sessionId: progress.sessionId,
|
||||
}),
|
||||
})
|
||||
)
|
||||
).catch(() => null);
|
||||
if (!responses || responses.some((response) => !response.ok)) {
|
||||
window.alert("The session could not be restarted. Your saved progress was kept.");
|
||||
return;
|
||||
}
|
||||
body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SEQUENTIAL" })
|
||||
}).catch(() => {}),
|
||||
fetch("/api/progress", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SHUFFLED" })
|
||||
}).catch(() => {})
|
||||
]);
|
||||
|
||||
// Clear locally and force remount
|
||||
setDeck({ ...deck, progress: [] });
|
||||
|
|
@ -82,8 +75,7 @@ export default function DeckStudyPage() {
|
|||
}, [deckId, classSlug, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => void fetchDeck(), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
fetchDeck();
|
||||
}, [fetchDeck]);
|
||||
|
||||
if (loading || !deck) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ImportModal } from "@/components/import/ImportModal";
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragStartEvent,
|
||||
DragOverlay,
|
||||
useDroppable,
|
||||
|
|
@ -26,7 +27,6 @@ import {
|
|||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { ReorderItem } from "@/types/study";
|
||||
|
||||
interface DeckItem {
|
||||
id: string;
|
||||
|
|
@ -40,8 +40,6 @@ interface DeckItem {
|
|||
currentIndex: number;
|
||||
orderJson: string;
|
||||
cardResultsJson: string | null;
|
||||
sessionId: string;
|
||||
revision: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
|
@ -51,25 +49,15 @@ interface MaterialGroup {
|
|||
sortOrder: number;
|
||||
}
|
||||
|
||||
const cache: Record<string, { decks: DeckItem[]; groups: MaterialGroup[] }> = {};
|
||||
|
||||
function getProgressLabel(deck: DeckItem) {
|
||||
if (!deck.progress?.length) return null;
|
||||
const prog = deck.progress[0];
|
||||
let order: string[];
|
||||
let results: Record<string, string>;
|
||||
try {
|
||||
const parsedOrder: unknown = JSON.parse(prog.orderJson);
|
||||
const parsedResults: unknown = prog.cardResultsJson
|
||||
? JSON.parse(prog.cardResultsJson)
|
||||
const order = JSON.parse(prog.orderJson) as string[];
|
||||
const results = prog.cardResultsJson
|
||||
? (JSON.parse(prog.cardResultsJson) as Record<string, string>)
|
||||
: {};
|
||||
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 total = order.length;
|
||||
const current = Math.min(prog.currentIndex + 1, total);
|
||||
|
|
@ -77,25 +65,7 @@ function getProgressLabel(deck: DeckItem) {
|
|||
return `${current}/${total}, ${modeLabel} · ${correctCount} ✓`;
|
||||
}
|
||||
|
||||
interface SortableDeckCardProps {
|
||||
deck: DeckItem;
|
||||
onEdit: (deck: DeckItem) => void;
|
||||
onDelete: (deck: DeckItem) => void;
|
||||
groups: MaterialGroup[];
|
||||
onMove: (deckId: string, groupId: string | null) => void;
|
||||
moveDisabled?: boolean;
|
||||
classSlug: string;
|
||||
}
|
||||
|
||||
function SortableDeckCard({
|
||||
deck,
|
||||
onEdit,
|
||||
onDelete,
|
||||
groups,
|
||||
onMove,
|
||||
moveDisabled = false,
|
||||
classSlug,
|
||||
}: SortableDeckCardProps) {
|
||||
function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: deck.id });
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
|
|
@ -129,8 +99,7 @@ function SortableDeckCard({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border-light pt-4 pl-7 sm:flex-row sm:items-center">
|
||||
<div className="flex w-full min-w-0 gap-2 sm:flex-1">
|
||||
<div className="flex items-center gap-2 pt-4 border-t border-border-light pl-7">
|
||||
{deck.progress?.length ? (
|
||||
<>
|
||||
<Link href={`/${classSlug}/flashcards/${deck.id}`} className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200">
|
||||
|
|
@ -139,24 +108,10 @@ function SortableDeckCard({
|
|||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm("Start over from the beginning? This will clear your current progress for this deck.")) return;
|
||||
const responses = await Promise.all(
|
||||
deck.progress.map((progress) =>
|
||||
fetch("/api/progress", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contentType: "DECK",
|
||||
contentId: deck.id,
|
||||
mode: progress.mode,
|
||||
sessionId: progress.sessionId,
|
||||
}),
|
||||
})
|
||||
)
|
||||
).catch(() => null);
|
||||
if (!responses || responses.some((response) => !response.ok)) {
|
||||
window.alert("The deck could not be restarted. Your saved progress was kept.");
|
||||
return;
|
||||
}
|
||||
await Promise.all([
|
||||
fetch("/api/progress", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SEQUENTIAL" }) }).catch(() => {}),
|
||||
fetch("/api/progress", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SHUFFLED" }) }).catch(() => {})
|
||||
]);
|
||||
window.location.href = `/${classSlug}/flashcards/${deck.id}`;
|
||||
}}
|
||||
className="flex-1 text-center py-2 px-4 rounded-lg bg-bg-surface-alt border border-border text-text-heading text-sm font-medium hover:bg-border transition-all duration-200 cursor-pointer shadow-sm"
|
||||
|
|
@ -170,22 +125,6 @@ function SortableDeckCard({
|
|||
Study
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<label className="sr-only" htmlFor={`move-deck-${deck.id}`}>Move {deck.name} to group</label>
|
||||
<select
|
||||
id={`move-deck-${deck.id}`}
|
||||
value={deck.groupId ?? ""}
|
||||
onChange={(event) => onMove(deck.id, event.target.value || null)}
|
||||
disabled={moveDisabled}
|
||||
className="min-h-9 max-w-36 rounded-lg border border-border bg-bg-surface-alt px-2 text-xs text-text-heading disabled:opacity-50"
|
||||
aria-label={`Move ${deck.name} to group`}
|
||||
>
|
||||
<option value="">Uncategorized</option>
|
||||
{groups.map((group) => (
|
||||
<option key={group.id} value={group.id}>{group.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={() => onEdit(deck)} className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer" title="Edit">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
|
|
@ -200,7 +139,6 @@ function SortableDeckCard({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -222,12 +160,10 @@ function DroppableContainer({ id, children }: { id: string; children: React.Reac
|
|||
export default function FlashcardsPage() {
|
||||
const params = useParams();
|
||||
const classSlug = params.classSlug as string;
|
||||
const [decks, setDecks] = useState<DeckItem[]>([]);
|
||||
const [groups, setGroups] = useState<MaterialGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const requestGeneration = useRef(0);
|
||||
const [decks, setDecks] = useState<DeckItem[]>(cache[classSlug]?.decks || []);
|
||||
const [groups, setGroups] = useState<MaterialGroup[]>(cache[classSlug]?.groups || []);
|
||||
const [loading, setLoading] = useState(!cache[classSlug]);
|
||||
const [animate] = useState(!cache[classSlug]);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [classId, setClassId] = useState<string>("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
|
@ -237,17 +173,13 @@ export default function FlashcardsPage() {
|
|||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
|
||||
const [editGroupName, setEditGroupName] = useState("");
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
const saved = localStorage.getItem('flashcards_collapsed_groups');
|
||||
if (saved) {
|
||||
try { setCollapsedGroups(JSON.parse(saved)); } catch {}
|
||||
try { setCollapsedGroups(JSON.parse(saved)); } catch(e) {}
|
||||
}
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
function toggleGroup(id: string) {
|
||||
|
|
@ -258,54 +190,43 @@ export default function FlashcardsPage() {
|
|||
});
|
||||
}
|
||||
|
||||
const fetchAll = useCallback(async (cId: string, generation = requestGeneration.current, signal?: AbortSignal) => {
|
||||
const fetchAll = useCallback(async (cId: string) => {
|
||||
try {
|
||||
const [deckRes, groupRes] = await Promise.all([
|
||||
fetch(`/api/decks/list?classId=${cId}`, { signal }),
|
||||
fetch(`/api/material-groups?classId=${cId}&type=DECK`, { signal }),
|
||||
fetch(`/api/decks/list?classId=${cId}`),
|
||||
fetch(`/api/material-groups?classId=${cId}&type=DECK`),
|
||||
]);
|
||||
if (!deckRes.ok || !groupRes.ok) throw new Error("Unable to load flashcard library");
|
||||
const ds = await deckRes.json();
|
||||
const gs = await groupRes.json();
|
||||
if (generation !== requestGeneration.current) return;
|
||||
const ds = deckRes.ok ? await deckRes.json() : [];
|
||||
const gs = groupRes.ok ? await groupRes.json() : [];
|
||||
setDecks(ds);
|
||||
setGroups(gs);
|
||||
setLoadError(null);
|
||||
} catch (error) {
|
||||
if (signal?.aborted || generation !== requestGeneration.current) return;
|
||||
setLoadError(error instanceof Error ? error.message : "Unable to load flashcard library");
|
||||
cache[classSlug] = { decks: ds, groups: gs };
|
||||
} catch {
|
||||
setDecks([]);
|
||||
setGroups([]);
|
||||
} finally {
|
||||
if (generation === requestGeneration.current) setLoading(false);
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [classSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const generation = ++requestGeneration.current;
|
||||
async function init() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
const classRes = await fetch("/api/classes", { signal: controller.signal });
|
||||
if (!classRes.ok) throw new Error("Unable to load class");
|
||||
const classRes = await fetch("/api/classes");
|
||||
const classes = await classRes.json();
|
||||
const cls = classes.find((c: { slug: string }) => c.slug === classSlug);
|
||||
if (!cls) throw new Error("Class not found");
|
||||
if (generation !== requestGeneration.current) return;
|
||||
if (!cls) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setClassId(cls.id);
|
||||
await fetchAll(cls.id, generation, controller.signal);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || generation !== requestGeneration.current) return;
|
||||
setLoadError(error instanceof Error ? error.message : "Unable to load flashcard library");
|
||||
fetchAll(cls.id);
|
||||
} catch {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
const timer = window.setTimeout(() => void init(), 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [classSlug, fetchAll, reloadKey]);
|
||||
init();
|
||||
}, [classSlug, fetchAll]);
|
||||
|
||||
// Group Management
|
||||
async function handleCreateGroup() {
|
||||
|
|
@ -317,71 +238,48 @@ export default function FlashcardsPage() {
|
|||
});
|
||||
if (res.ok) {
|
||||
const g = await res.json();
|
||||
setGroups((prev) => [g, ...prev]);
|
||||
setGroups((prev) => [...prev, g]);
|
||||
setIsCreatingGroup(false);
|
||||
setNewGroupName("");
|
||||
setActionMessage(`Created group ${g.name}.`);
|
||||
} else {
|
||||
setActionMessage("The group could not be created. Please retry.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRenameGroup(id: string) {
|
||||
if (!editGroupName.trim()) return;
|
||||
const response = await fetch(`/api/material-groups/${id}`, {
|
||||
await fetch(`/api/material-groups/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: editGroupName.trim() }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
setActionMessage("The group could not be renamed. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name: editGroupName.trim() } : g)));
|
||||
setEditingGroupId(null);
|
||||
setActionMessage("Group renamed.");
|
||||
}
|
||||
|
||||
async function handleDeleteGroup(id: string, name: string) {
|
||||
if (!confirm(`Delete group "${name}"? Decks inside will be moved to Uncategorized.`)) return;
|
||||
const response = await fetch(`/api/material-groups/${id}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
setActionMessage("The group could not be deleted. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
await fetchAll(classId);
|
||||
setActionMessage(`Deleted group ${name}; its decks are now Uncategorized.`);
|
||||
await fetch(`/api/material-groups/${id}`, { method: "DELETE" });
|
||||
setGroups((prev) => prev.filter((g) => g.id !== id));
|
||||
setDecks((prev) => prev.map((d) => (d.groupId === id ? { ...d, groupId: null } : d)));
|
||||
}
|
||||
|
||||
// Deck Management
|
||||
async function handleDeleteDeck(deck: DeckItem) {
|
||||
if (!confirm(`Delete "${deck.name}" and all its cards?`)) return;
|
||||
const response = await fetch(`/api/decks/${deck.id}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
setActionMessage("The deck could not be deleted. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
await fetch(`/api/decks/${deck.id}`, { method: "DELETE" });
|
||||
setDecks((prev) => prev.filter((d) => d.id !== deck.id));
|
||||
window.dispatchEvent(new Event("study-decks-changed"));
|
||||
setActionMessage(`Deleted deck ${deck.name}.`);
|
||||
}
|
||||
|
||||
async function handleRenameDeck(id: string) {
|
||||
if (!editName.trim()) return;
|
||||
const response = await fetch(`/api/decks/${id}`, {
|
||||
await fetch(`/api/decks/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: editName.trim(), description: editDescription.trim() || null }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
setActionMessage("The deck could not be updated. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
setDecks((prev) =>
|
||||
prev.map((d) => (d.id === id ? { ...d, name: editName.trim(), description: editDescription.trim() || null } : d))
|
||||
);
|
||||
setEditingId(null);
|
||||
setActionMessage("Deck updated.");
|
||||
}
|
||||
|
||||
// Drag and drop setup
|
||||
|
|
@ -391,13 +289,15 @@ export default function FlashcardsPage() {
|
|||
);
|
||||
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [isReordering, setIsReordering] = useState(false);
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
if (isReordering) return;
|
||||
setActiveId(event.active.id as string);
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragOverEvent) {
|
||||
// optional layout updates here
|
||||
}
|
||||
|
||||
async function handleDragEnd(event: DragEndEvent) {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
|
|
@ -410,6 +310,8 @@ export default function FlashcardsPage() {
|
|||
if (!activeDeck) return;
|
||||
|
||||
let targetGroupId: string | null = null;
|
||||
let targetIndex = 0;
|
||||
|
||||
const overContainerId = over.data.current?.sortable?.containerId;
|
||||
if (overContainerId) {
|
||||
targetGroupId = overContainerId === "uncategorized" ? null : overContainerId;
|
||||
|
|
@ -422,76 +324,40 @@ export default function FlashcardsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const oldIndex = decks.findIndex((deck) => deck.id === activeId);
|
||||
const overIndex = decks.findIndex((deck) => deck.id === overId);
|
||||
let nextItems = decks.map((deck) =>
|
||||
deck.id === activeId ? { ...deck, groupId: targetGroupId } : { ...deck }
|
||||
);
|
||||
if (targetGroupId !== undefined) {
|
||||
setDecks((items) => {
|
||||
const oldIndex = items.findIndex((d) => d.id === activeId);
|
||||
const overIndex = items.findIndex((d) => d.id === overId);
|
||||
let newItems = [...items];
|
||||
|
||||
newItems[oldIndex].groupId = targetGroupId;
|
||||
|
||||
if (overIndex >= 0 && overIndex !== oldIndex) {
|
||||
nextItems = arrayMove(nextItems, oldIndex, overIndex);
|
||||
newItems = arrayMove(newItems, oldIndex, overIndex);
|
||||
} else {
|
||||
const [moved] = nextItems.splice(oldIndex, 1);
|
||||
nextItems.push(moved);
|
||||
}
|
||||
await persistDeckReorder(
|
||||
decks,
|
||||
nextItems,
|
||||
new Set([activeDeck.groupId, targetGroupId])
|
||||
);
|
||||
const oldItem = newItems.splice(oldIndex, 1)[0];
|
||||
newItems.push(oldItem);
|
||||
}
|
||||
|
||||
async function persistDeckReorder(
|
||||
previousItems: DeckItem[],
|
||||
proposedItems: DeckItem[],
|
||||
affectedGroups: Set<string | null>
|
||||
) {
|
||||
const counters = new Map<string | null, number>();
|
||||
const nextItems = proposedItems.map((item) => {
|
||||
if (!affectedGroups.has(item.groupId)) return item;
|
||||
const sortOrder = counters.get(item.groupId) ?? 0;
|
||||
counters.set(item.groupId, sortOrder + 1);
|
||||
return { ...item, sortOrder };
|
||||
const affectedGroups = new Set([activeDeck.groupId, targetGroupId]);
|
||||
const updates: any[] = [];
|
||||
|
||||
affectedGroups.forEach(gId => {
|
||||
const gItems = newItems.filter(d => d.groupId === gId);
|
||||
gItems.forEach((item, index) => {
|
||||
item.sortOrder = index;
|
||||
updates.push({ id: item.id, sortOrder: index, groupId: item.groupId });
|
||||
});
|
||||
});
|
||||
const updates: ReorderItem[] = nextItems
|
||||
.filter((item) => affectedGroups.has(item.groupId))
|
||||
.map((item) => ({ id: item.id, groupId: item.groupId }));
|
||||
|
||||
setDecks(nextItems);
|
||||
setIsReordering(true);
|
||||
try {
|
||||
const response = await fetch("/api/decks/reorder", {
|
||||
fetch("/api/decks/reorder", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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}.`);
|
||||
return newItems;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -505,11 +371,6 @@ export default function FlashcardsPage() {
|
|||
|
||||
return (
|
||||
<div className="pb-20">
|
||||
{actionMessage && (
|
||||
<p className="mb-4 rounded-xl bg-badge-bg px-4 py-3 text-sm text-text-secondary" role="status" aria-live="polite">
|
||||
{actionMessage}
|
||||
</p>
|
||||
)}
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Collection</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Flashcard decks</h2></div>
|
||||
|
|
@ -572,23 +433,15 @@ export default function FlashcardsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!loading && loadError && (
|
||||
<div role="alert" className="mb-6 rounded-2xl border border-error/30 bg-error-bg p-5 text-error">
|
||||
<p className="font-bold">Flashcard library could not be loaded.</p>
|
||||
<p className="mt-1 text-sm">{loadError}</p>
|
||||
<button onClick={() => setReloadKey((value) => value + 1)} className="mt-3 min-h-10 rounded-lg border border-error/40 px-4 text-sm font-bold">Retry</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !loadError && decks.length === 0 && groups.length === 0 && (
|
||||
{!loading && decks.length === 0 && groups.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<h3 className="text-lg font-semibold text-text-heading mb-1">No decks yet</h3>
|
||||
<p className="text-text-secondary mb-4">Import your first deck or create a group</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !loadError && (
|
||||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
{!loading && (
|
||||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
|
||||
<div className="space-y-8">
|
||||
{groupedDecks.map(group => (
|
||||
<div key={group.id} className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
|
||||
|
|
@ -622,7 +475,7 @@ export default function FlashcardsPage() {
|
|||
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">Drop decks here</div>
|
||||
) : (
|
||||
group.decks.map(deck => (
|
||||
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} groups={groups} onMove={handleMoveDeck} moveDisabled={isReordering} classSlug={classSlug} />
|
||||
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} classSlug={classSlug} />
|
||||
))
|
||||
)}
|
||||
</DroppableContainer>
|
||||
|
|
@ -647,7 +500,7 @@ export default function FlashcardsPage() {
|
|||
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">No uncategorized decks</div>
|
||||
) : (
|
||||
uncategorizedDecks.map(deck => (
|
||||
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} groups={groups} onMove={handleMoveDeck} moveDisabled={isReordering} classSlug={classSlug} />
|
||||
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} classSlug={classSlug} />
|
||||
))
|
||||
)}
|
||||
</DroppableContainer>
|
||||
|
|
@ -659,7 +512,7 @@ export default function FlashcardsPage() {
|
|||
<DragOverlay>
|
||||
{activeDeck ? (
|
||||
<div className="opacity-80 scale-105 shadow-xl rotate-2">
|
||||
<SortableDeckCard deck={activeDeck} onEdit={()=>{}} onDelete={()=>{}} groups={groups} onMove={()=>{}} moveDisabled classSlug={classSlug} />
|
||||
<SortableDeckCard deck={activeDeck} onEdit={()=>{}} onDelete={()=>{}} classSlug={classSlug} />
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ export default async function ClassLayout(props: LayoutProps<"/[classSlug]">) {
|
|||
|
||||
return (
|
||||
<div className="app-page">
|
||||
{/* Dynamic class header */}
|
||||
<ClassHeader className={classData.name} />
|
||||
{/* Dynamic Header & Tabs */}
|
||||
<ClassHeader classSlug={classSlug} className={classData.name} />
|
||||
|
||||
{/* Page content */}
|
||||
{props.children}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ interface QuizAttempt {
|
|||
score: number;
|
||||
maxScore: number;
|
||||
answersJson: string;
|
||||
reviewSnapshotJson?: string | null;
|
||||
isPartialRetake: boolean;
|
||||
completedAt: string;
|
||||
}
|
||||
|
|
@ -43,8 +42,6 @@ interface QuizData {
|
|||
currentIndex: number;
|
||||
orderJson: string;
|
||||
answersJson: string | null;
|
||||
sessionId: string;
|
||||
revision: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
|
@ -63,23 +60,13 @@ export default function QuizStudyPage() {
|
|||
|
||||
async function handleRestart() {
|
||||
if (!quiz) return;
|
||||
const progress = quiz.progress.find((item) => item.mode === "SEQUENTIAL");
|
||||
if (progress) {
|
||||
const response = await fetch("/api/progress", {
|
||||
|
||||
// Clear progress in database
|
||||
await fetch("/api/progress", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contentType: "QUIZ",
|
||||
contentId: quiz.id,
|
||||
mode: "SEQUENTIAL",
|
||||
sessionId: progress.sessionId,
|
||||
}),
|
||||
}).catch(() => null);
|
||||
if (!response?.ok) {
|
||||
window.alert("The quiz could not be restarted. Your saved progress was kept.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
body: JSON.stringify({ contentType: "QUIZ", contentId: quiz.id, mode: "SEQUENTIAL" })
|
||||
}).catch(() => {});
|
||||
|
||||
// Clear locally and force remount
|
||||
setQuiz({ ...quiz, progress: [] });
|
||||
|
|
@ -117,8 +104,7 @@ export default function QuizStudyPage() {
|
|||
}, [quizId, classSlug, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => void fetchQuizAndAttempts(), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
fetchQuizAndAttempts();
|
||||
}, [fetchQuizAndAttempts]);
|
||||
|
||||
const [showTopics, setShowTopics] = useState(false);
|
||||
|
|
@ -235,7 +221,6 @@ export default function QuizStudyPage() {
|
|||
key={restartKey}
|
||||
quiz={quiz}
|
||||
retakeIds={retakeIds}
|
||||
sessionKey={restartKey}
|
||||
onFinished={() => {
|
||||
router.push(`/${classSlug}/quizzes`);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ImportModal } from "@/components/import/ImportModal";
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragStartEvent,
|
||||
DragOverlay,
|
||||
useDroppable,
|
||||
|
|
@ -26,7 +27,6 @@ import {
|
|||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { ReorderItem } from "@/types/study";
|
||||
|
||||
interface QuizItem {
|
||||
id: string;
|
||||
|
|
@ -35,14 +35,6 @@ interface QuizItem {
|
|||
groupId: string | null;
|
||||
sortOrder: number;
|
||||
_count: { questions: number; attempts: number };
|
||||
progress: Array<{
|
||||
mode: string;
|
||||
currentIndex: number;
|
||||
orderJson: string;
|
||||
answersJson: string | null;
|
||||
sessionId: string;
|
||||
revision: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface MaterialGroup {
|
||||
|
|
@ -51,25 +43,9 @@ interface MaterialGroup {
|
|||
sortOrder: number;
|
||||
}
|
||||
|
||||
interface SortableQuizCardProps {
|
||||
quiz: QuizItem;
|
||||
onEdit: (quiz: QuizItem) => void;
|
||||
onDelete: (quiz: QuizItem) => void;
|
||||
groups: MaterialGroup[];
|
||||
onMove: (quizId: string, groupId: string | null) => void;
|
||||
moveDisabled?: boolean;
|
||||
classSlug: string;
|
||||
}
|
||||
const cache: Record<string, { quizzes: QuizItem[]; groups: MaterialGroup[] }> = {};
|
||||
|
||||
function SortableQuizCard({
|
||||
quiz,
|
||||
onEdit,
|
||||
onDelete,
|
||||
groups,
|
||||
onMove,
|
||||
moveDisabled = false,
|
||||
classSlug,
|
||||
}: SortableQuizCardProps) {
|
||||
function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: quiz.id });
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
|
|
@ -103,61 +79,10 @@ function SortableQuizCard({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2 border-t border-border-light pt-4 pl-7 sm:flex-row sm:items-center">
|
||||
<div className="flex w-full min-w-0 gap-2 sm:flex-1">
|
||||
{quiz.progress?.length ? (
|
||||
<>
|
||||
<Link href={`/${classSlug}/quizzes/${quiz.id}`} className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200">
|
||||
Continue
|
||||
</Link>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm("Start over from the beginning? This will clear your current progress for this quiz.")) return;
|
||||
const progress = quiz.progress.find((item) => item.mode === "SEQUENTIAL");
|
||||
if (!progress) return;
|
||||
const response = await fetch("/api/progress", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contentType: "QUIZ",
|
||||
contentId: quiz.id,
|
||||
mode: "SEQUENTIAL",
|
||||
sessionId: progress.sessionId,
|
||||
}),
|
||||
}).catch(() => null);
|
||||
if (!response?.ok) {
|
||||
window.alert("The quiz could not be restarted. Your saved progress was kept.");
|
||||
return;
|
||||
}
|
||||
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"
|
||||
title="Restart"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-border-light pl-7">
|
||||
<Link href={`/${classSlug}/quizzes/${quiz.id}`} className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200">
|
||||
Start Quiz
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<label className="sr-only" htmlFor={`move-quiz-${quiz.id}`}>Move {quiz.name} to group</label>
|
||||
<select
|
||||
id={`move-quiz-${quiz.id}`}
|
||||
value={quiz.groupId ?? ""}
|
||||
onChange={(event) => onMove(quiz.id, event.target.value || null)}
|
||||
disabled={moveDisabled}
|
||||
className="min-h-9 max-w-36 rounded-lg border border-border bg-bg-surface-alt px-2 text-xs text-text-heading disabled:opacity-50"
|
||||
aria-label={`Move ${quiz.name} to group`}
|
||||
>
|
||||
<option value="">Uncategorized</option>
|
||||
{groups.map((group) => (
|
||||
<option key={group.id} value={group.id}>{group.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={() => onEdit(quiz)} className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer" title="Edit">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
|
|
@ -172,7 +97,6 @@ function SortableQuizCard({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -194,12 +118,10 @@ function DroppableContainer({ id, children }: { id: string; children: React.Reac
|
|||
export default function QuizzesPage() {
|
||||
const params = useParams();
|
||||
const classSlug = params.classSlug as string;
|
||||
const [quizzes, setQuizzes] = useState<QuizItem[]>([]);
|
||||
const [groups, setGroups] = useState<MaterialGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const requestGeneration = useRef(0);
|
||||
const [quizzes, setQuizzes] = useState<QuizItem[]>(cache[classSlug]?.quizzes || []);
|
||||
const [groups, setGroups] = useState<MaterialGroup[]>(cache[classSlug]?.groups || []);
|
||||
const [loading, setLoading] = useState(!cache[classSlug]);
|
||||
const [animate] = useState(!cache[classSlug]);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [classId, setClassId] = useState<string>("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
|
@ -209,17 +131,13 @@ export default function QuizzesPage() {
|
|||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
|
||||
const [editGroupName, setEditGroupName] = useState("");
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
const saved = localStorage.getItem('quizzes_collapsed_groups');
|
||||
if (saved) {
|
||||
try { setCollapsedGroups(JSON.parse(saved)); } catch {}
|
||||
try { setCollapsedGroups(JSON.parse(saved)); } catch(e) {}
|
||||
}
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
function toggleGroup(id: string) {
|
||||
|
|
@ -230,54 +148,43 @@ export default function QuizzesPage() {
|
|||
});
|
||||
}
|
||||
|
||||
const fetchAll = useCallback(async (cId: string, generation = requestGeneration.current, signal?: AbortSignal) => {
|
||||
const fetchAll = useCallback(async (cId: string) => {
|
||||
try {
|
||||
const [quizRes, groupRes] = await Promise.all([
|
||||
fetch(`/api/quizzes/list?classId=${cId}`, { signal }),
|
||||
fetch(`/api/material-groups?classId=${cId}&type=QUIZ`, { signal }),
|
||||
fetch(`/api/quizzes/list?classId=${cId}`),
|
||||
fetch(`/api/material-groups?classId=${cId}&type=QUIZ`),
|
||||
]);
|
||||
if (!quizRes.ok || !groupRes.ok) throw new Error("Unable to load quiz library");
|
||||
const qs = await quizRes.json();
|
||||
const gs = await groupRes.json();
|
||||
if (generation !== requestGeneration.current) return;
|
||||
const qs = quizRes.ok ? await quizRes.json() : [];
|
||||
const gs = groupRes.ok ? await groupRes.json() : [];
|
||||
setQuizzes(qs);
|
||||
setGroups(gs);
|
||||
setLoadError(null);
|
||||
} catch (error) {
|
||||
if (signal?.aborted || generation !== requestGeneration.current) return;
|
||||
setLoadError(error instanceof Error ? error.message : "Unable to load quiz library");
|
||||
cache[classSlug] = { quizzes: qs, groups: gs };
|
||||
} catch {
|
||||
setQuizzes([]);
|
||||
setGroups([]);
|
||||
} finally {
|
||||
if (generation === requestGeneration.current) setLoading(false);
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [classSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const generation = ++requestGeneration.current;
|
||||
async function init() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
const classRes = await fetch("/api/classes", { signal: controller.signal });
|
||||
if (!classRes.ok) throw new Error("Unable to load class");
|
||||
const classRes = await fetch("/api/classes");
|
||||
const classes = await classRes.json();
|
||||
const cls = classes.find((c: { slug: string }) => c.slug === classSlug);
|
||||
if (!cls) throw new Error("Class not found");
|
||||
if (generation !== requestGeneration.current) return;
|
||||
if (!cls) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setClassId(cls.id);
|
||||
await fetchAll(cls.id, generation, controller.signal);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || generation !== requestGeneration.current) return;
|
||||
setLoadError(error instanceof Error ? error.message : "Unable to load quiz library");
|
||||
fetchAll(cls.id);
|
||||
} catch {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
const timer = window.setTimeout(() => void init(), 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [classSlug, fetchAll, reloadKey]);
|
||||
init();
|
||||
}, [classSlug, fetchAll]);
|
||||
|
||||
// Group Management
|
||||
async function handleCreateGroup() {
|
||||
|
|
@ -289,70 +196,48 @@ export default function QuizzesPage() {
|
|||
});
|
||||
if (res.ok) {
|
||||
const g = await res.json();
|
||||
setGroups((prev) => [g, ...prev]);
|
||||
setGroups((prev) => [...prev, g]);
|
||||
setIsCreatingGroup(false);
|
||||
setNewGroupName("");
|
||||
setActionMessage(`Created group ${g.name}.`);
|
||||
} else {
|
||||
setActionMessage("The group could not be created. Please retry.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRenameGroup(id: string) {
|
||||
if (!editGroupName.trim()) return;
|
||||
const response = await fetch(`/api/material-groups/${id}`, {
|
||||
await fetch(`/api/material-groups/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: editGroupName.trim() }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
setActionMessage("The group could not be renamed. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name: editGroupName.trim() } : g)));
|
||||
setEditingGroupId(null);
|
||||
setActionMessage("Group renamed.");
|
||||
}
|
||||
|
||||
async function handleDeleteGroup(id: string, name: string) {
|
||||
if (!confirm(`Delete group "${name}"? Quizzes inside will be moved to Uncategorized.`)) return;
|
||||
const response = await fetch(`/api/material-groups/${id}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
setActionMessage("The group could not be deleted. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
await fetchAll(classId);
|
||||
setActionMessage(`Deleted group ${name}; its quizzes are now Uncategorized.`);
|
||||
await fetch(`/api/material-groups/${id}`, { method: "DELETE" });
|
||||
setGroups((prev) => prev.filter((g) => g.id !== id));
|
||||
setQuizzes((prev) => prev.map((q) => (q.groupId === id ? { ...q, groupId: null } : q)));
|
||||
}
|
||||
|
||||
// Quiz Management
|
||||
async function handleDeleteQuiz(quiz: QuizItem) {
|
||||
if (!confirm(`Delete "${quiz.name}" and all its questions and attempts?`)) return;
|
||||
const response = await fetch(`/api/quizzes/${quiz.id}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
setActionMessage("The quiz could not be deleted. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
await fetch(`/api/quizzes/${quiz.id}`, { method: "DELETE" });
|
||||
setQuizzes((prev) => prev.filter((q) => q.id !== quiz.id));
|
||||
setActionMessage(`Deleted quiz ${quiz.name}.`);
|
||||
}
|
||||
|
||||
async function handleRenameQuiz(id: string) {
|
||||
if (!editName.trim()) return;
|
||||
const response = await fetch(`/api/quizzes/${id}`, {
|
||||
await fetch(`/api/quizzes/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: editName.trim(), description: editDescription.trim() || null }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
setActionMessage("The quiz could not be updated. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
setQuizzes((prev) =>
|
||||
prev.map((q) => (q.id === id ? { ...q, name: editName.trim(), description: editDescription.trim() || null } : q))
|
||||
);
|
||||
setEditingId(null);
|
||||
setActionMessage("Quiz updated.");
|
||||
}
|
||||
|
||||
// Drag and drop setup
|
||||
|
|
@ -362,13 +247,26 @@ export default function QuizzesPage() {
|
|||
);
|
||||
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [isReordering, setIsReordering] = useState(false);
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
if (isReordering) return;
|
||||
setActiveId(event.active.id as string);
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragOverEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const activeId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
if (activeId === overId) return;
|
||||
|
||||
// Is it dropping over a container directly?
|
||||
const isOverContainer = over.data.current?.sortable?.containerId || overId;
|
||||
// ... we handle layout updates in dragEnd to keep it simple, or here for smooth lists.
|
||||
// For simplicity, we just handle the final move in dragEnd.
|
||||
}
|
||||
|
||||
async function handleDragEnd(event: DragEndEvent) {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
|
|
@ -381,6 +279,8 @@ export default function QuizzesPage() {
|
|||
if (!activeQuiz) return;
|
||||
|
||||
let targetGroupId: string | null = null;
|
||||
let targetIndex = 0;
|
||||
|
||||
// Check if over a group container directly
|
||||
const overContainerId = over.data.current?.sortable?.containerId;
|
||||
if (overContainerId) {
|
||||
|
|
@ -394,76 +294,43 @@ export default function QuizzesPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const oldIndex = quizzes.findIndex((quiz) => quiz.id === activeId);
|
||||
const overIndex = quizzes.findIndex((quiz) => quiz.id === overId);
|
||||
let nextItems = quizzes.map((quiz) =>
|
||||
quiz.id === activeId ? { ...quiz, groupId: targetGroupId } : { ...quiz }
|
||||
);
|
||||
if (targetGroupId !== undefined) {
|
||||
setQuizzes((items) => {
|
||||
const oldIndex = items.findIndex((q) => q.id === activeId);
|
||||
const overIndex = items.findIndex((q) => q.id === overId);
|
||||
let newItems = [...items];
|
||||
|
||||
newItems[oldIndex].groupId = targetGroupId;
|
||||
|
||||
if (overIndex >= 0 && overIndex !== oldIndex) {
|
||||
nextItems = arrayMove(nextItems, oldIndex, overIndex);
|
||||
newItems = arrayMove(newItems, oldIndex, overIndex);
|
||||
} else {
|
||||
const [moved] = nextItems.splice(oldIndex, 1);
|
||||
nextItems.push(moved);
|
||||
}
|
||||
await persistQuizReorder(
|
||||
quizzes,
|
||||
nextItems,
|
||||
new Set([activeQuiz.groupId, targetGroupId])
|
||||
);
|
||||
// just moved to end of a group
|
||||
const oldItem = newItems.splice(oldIndex, 1)[0];
|
||||
newItems.push(oldItem);
|
||||
}
|
||||
|
||||
async function persistQuizReorder(
|
||||
previousItems: QuizItem[],
|
||||
proposedItems: QuizItem[],
|
||||
affectedGroups: Set<string | null>
|
||||
) {
|
||||
const counters = new Map<string | null, number>();
|
||||
const nextItems = proposedItems.map((item) => {
|
||||
if (!affectedGroups.has(item.groupId)) return item;
|
||||
const sortOrder = counters.get(item.groupId) ?? 0;
|
||||
counters.set(item.groupId, sortOrder + 1);
|
||||
return { ...item, sortOrder };
|
||||
// Re-calculate sortOrder for the affected groups to persist
|
||||
const affectedGroups = new Set([activeQuiz.groupId, targetGroupId]);
|
||||
const updates: any[] = [];
|
||||
|
||||
affectedGroups.forEach(gId => {
|
||||
const gItems = newItems.filter(q => q.groupId === gId);
|
||||
gItems.forEach((item, index) => {
|
||||
item.sortOrder = index;
|
||||
updates.push({ id: item.id, sortOrder: index, groupId: item.groupId });
|
||||
});
|
||||
});
|
||||
const updates: ReorderItem[] = nextItems
|
||||
.filter((item) => affectedGroups.has(item.groupId))
|
||||
.map((item) => ({ id: item.id, groupId: item.groupId }));
|
||||
|
||||
setQuizzes(nextItems);
|
||||
setIsReordering(true);
|
||||
try {
|
||||
const response = await fetch("/api/quizzes/reorder", {
|
||||
// Fire API
|
||||
fetch("/api/quizzes/reorder", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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}.`);
|
||||
return newItems;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -478,11 +345,6 @@ export default function QuizzesPage() {
|
|||
|
||||
return (
|
||||
<div className="pb-20">
|
||||
{actionMessage && (
|
||||
<p className="mb-4 rounded-xl bg-badge-bg px-4 py-3 text-sm text-text-secondary" role="status" aria-live="polite">
|
||||
{actionMessage}
|
||||
</p>
|
||||
)}
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Practice</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Practice quizzes</h2></div>
|
||||
|
|
@ -546,23 +408,15 @@ export default function QuizzesPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!loading && loadError && (
|
||||
<div role="alert" className="mb-6 rounded-2xl border border-error/30 bg-error-bg p-5 text-error">
|
||||
<p className="font-bold">Quiz library could not be loaded.</p>
|
||||
<p className="mt-1 text-sm">{loadError}</p>
|
||||
<button onClick={() => setReloadKey((value) => value + 1)} className="mt-3 min-h-10 rounded-lg border border-error/40 px-4 text-sm font-bold">Retry</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !loadError && quizzes.length === 0 && groups.length === 0 && (
|
||||
{!loading && quizzes.length === 0 && groups.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<h3 className="text-lg font-semibold text-text-heading mb-1">No quizzes yet</h3>
|
||||
<p className="text-text-secondary mb-4">Import your first quiz or create a group</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !loadError && (
|
||||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
{!loading && (
|
||||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
|
||||
<div className="space-y-8">
|
||||
{groupedQuizzes.map(group => (
|
||||
<div key={group.id} className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
|
||||
|
|
@ -583,7 +437,7 @@ export default function QuizzesPage() {
|
|||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<ShareMenu targetType="GROUP" contentId={group.id} classSlug={params.classSlug as string} containsQuizAnswers />
|
||||
<ShareMenu targetType="GROUP" contentId={group.id} classSlug={params.classSlug as string} />
|
||||
<button onClick={() => { setEditingGroupId(group.id); setEditGroupName(group.name); }} className="p-1.5 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer"><svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg></button>
|
||||
<button onClick={() => handleDeleteGroup(group.id, group.name)} className="p-1.5 rounded-lg text-text-muted hover:text-error hover:bg-bg-surface transition-colors cursor-pointer"><svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg></button>
|
||||
</div>
|
||||
|
|
@ -596,7 +450,7 @@ export default function QuizzesPage() {
|
|||
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">Drop quizzes here</div>
|
||||
) : (
|
||||
group.quizzes.map(quiz => (
|
||||
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} groups={groups} onMove={handleMoveQuiz} moveDisabled={isReordering} classSlug={classSlug} />
|
||||
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} classSlug={classSlug} />
|
||||
))
|
||||
)}
|
||||
</DroppableContainer>
|
||||
|
|
@ -621,7 +475,7 @@ export default function QuizzesPage() {
|
|||
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">No uncategorized quizzes</div>
|
||||
) : (
|
||||
uncategorizedQuizzes.map(quiz => (
|
||||
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} groups={groups} onMove={handleMoveQuiz} moveDisabled={isReordering} classSlug={classSlug} />
|
||||
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} classSlug={classSlug} />
|
||||
))
|
||||
)}
|
||||
</DroppableContainer>
|
||||
|
|
@ -633,7 +487,7 @@ export default function QuizzesPage() {
|
|||
<DragOverlay>
|
||||
{activeQuiz ? (
|
||||
<div className="opacity-80 scale-105 shadow-xl rotate-2">
|
||||
<SortableQuizCard quiz={activeQuiz} onEdit={()=>{}} onDelete={()=>{}} groups={groups} onMove={()=>{}} moveDisabled classSlug={classSlug} />
|
||||
<SortableQuizCard quiz={activeQuiz} onEdit={()=>{}} onDelete={()=>{}} classSlug={classSlug} />
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
import { SpacedRepetitionViewer } from "@/components/spaced-repetition/SpacedRepetitionViewer";
|
||||
|
||||
export default function SpacedRepetitionStudyPage() {
|
||||
return <SpacedRepetitionViewer />;
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { SpacedRepetitionSets } from "@/components/spaced-repetition/SpacedRepetitionSets";
|
||||
|
||||
export default function SpacedRepetitionPage() {
|
||||
return <SpacedRepetitionSets />;
|
||||
}
|
||||
|
|
@ -2,17 +2,12 @@
|
|||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ActivityBanner } from "@/components/activity/ActivityBanner";
|
||||
|
||||
interface ClassItem {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
_count: { decks: number; quizSets: number };
|
||||
spacedRepetitionDueCards: number;
|
||||
spacedRepetitionLearningCards: number;
|
||||
spacedRepetitionNewCards: number;
|
||||
spacedRepetitionReadyCards: number;
|
||||
}
|
||||
|
||||
const accents = ["#4f46e5", "#f97360", "#25845f", "#c27718", "#8b5cf6", "#0284c7"];
|
||||
|
|
@ -20,7 +15,6 @@ const accents = ["#4f46e5", "#f97360", "#25845f", "#c27718", "#8b5cf6", "#0284c7
|
|||
export default function HomePage() {
|
||||
const [classes, setClasses] = useState<ClassItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
|
@ -28,25 +22,13 @@ export default function HomePage() {
|
|||
const [editName, setEditName] = useState("");
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetchClasses(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
useEffect(() => { fetchClasses(); }, []);
|
||||
|
||||
async function fetchClasses(signal?: AbortSignal) {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
async function fetchClasses() {
|
||||
try {
|
||||
const res = await fetch("/api/classes", { signal });
|
||||
if (!res.ok) throw new Error("The server could not load your classes.");
|
||||
const res = await fetch("/api/classes");
|
||||
setClasses(await res.json());
|
||||
} catch (error) {
|
||||
if (signal?.aborted) return;
|
||||
setLoadError(error instanceof Error ? error.message : "Unable to load classes.");
|
||||
} finally {
|
||||
if (!signal?.aborted) setLoading(false);
|
||||
}
|
||||
} finally { setLoading(false); }
|
||||
}
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
|
|
@ -55,21 +37,14 @@ export default function HomePage() {
|
|||
setCreating(true);
|
||||
try {
|
||||
const res = await fetch("/api/classes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newName.trim() }) });
|
||||
if (!res.ok) throw new Error("The class could not be created.");
|
||||
setNewName(""); setShowCreate(false); void fetchClasses();
|
||||
} catch (error) {
|
||||
setLoadError(error instanceof Error ? error.message : "The class could not be created.");
|
||||
if (res.ok) { setNewName(""); setShowCreate(false); fetchClasses(); }
|
||||
} finally { setCreating(false); }
|
||||
}
|
||||
|
||||
async function handleDelete(id: string, name: string) {
|
||||
if (!confirm(`Delete "${name}" and all its decks and quizzes?`)) return;
|
||||
const response = await fetch(`/api/classes/${id}`, { method: "DELETE" }).catch(() => null);
|
||||
if (!response?.ok) {
|
||||
setLoadError("The class could not be deleted. Retry when the server is available.");
|
||||
return;
|
||||
}
|
||||
void fetchClasses();
|
||||
await fetch(`/api/classes/${id}`, { method: "DELETE" });
|
||||
fetchClasses();
|
||||
}
|
||||
|
||||
async function handleSaveEdit(id: string) {
|
||||
|
|
@ -77,16 +52,37 @@ export default function HomePage() {
|
|||
setSavingEdit(true);
|
||||
try {
|
||||
const res = await fetch(`/api/classes/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: editName.trim() }) });
|
||||
if (!res.ok) throw new Error("The class could not be renamed.");
|
||||
setEditingId(null); void fetchClasses();
|
||||
} catch (error) {
|
||||
setLoadError(error instanceof Error ? error.message : "The class could not be renamed.");
|
||||
if (res.ok) { setEditingId(null); fetchClasses(); }
|
||||
} finally { setSavingEdit(false); }
|
||||
}
|
||||
|
||||
const deckTotal = classes.reduce((sum, item) => sum + item._count.decks, 0);
|
||||
const quizTotal = classes.reduce((sum, item) => sum + item._count.quizSets, 0);
|
||||
|
||||
return (
|
||||
<div className="app-page">
|
||||
<ActivityBanner onNewClass={() => setShowCreate(true)} />
|
||||
<section className="relative mb-10 overflow-hidden rounded-[2rem] bg-[#172033] px-6 py-8 text-white shadow-[var(--shadow-card)] sm:px-9 sm:py-10 dark:bg-[#111827]">
|
||||
<div className="absolute -right-16 -top-24 h-64 w-64 rounded-full bg-primary/70 blur-3xl" />
|
||||
<div className="absolute -bottom-28 right-1/3 h-52 w-52 rounded-full bg-accent/35 blur-3xl" />
|
||||
<div className="relative flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="mb-3 text-xs font-bold uppercase tracking-[0.22em] text-white/55">Personal library</p>
|
||||
<h1 className="editorial-title max-w-2xl text-4xl leading-[1.02] sm:text-5xl">What are we learning today?</h1>
|
||||
<p className="mt-4 max-w-xl text-sm leading-6 text-white/65 sm:text-base">Pick up where you left off, sharpen a weak topic, or build something new.</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(true)} className="inline-flex min-h-12 items-center justify-center gap-2 self-start rounded-xl bg-white px-5 text-sm font-bold text-[#172033] shadow-lg transition-transform hover:-translate-y-0.5 lg:self-auto">
|
||||
<span className="text-xl leading-none">+</span> New class
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative mt-8 grid max-w-xl grid-cols-3 gap-3 border-t border-white/12 pt-6">
|
||||
{[[classes.length, "Classes"], [deckTotal, "Decks"], [quizTotal, "Quizzes"]].map(([value, label]) => (
|
||||
<div key={label}>
|
||||
<div className="text-2xl font-extrabold sm:text-3xl">{value}</div>
|
||||
<div className="mt-1 text-xs font-semibold text-white/50">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{showCreate && (
|
||||
<section className="animate-slide-up mb-8 rounded-2xl border border-primary/20 bg-bg-surface p-5 shadow-[var(--shadow-card)] sm:p-6">
|
||||
|
|
@ -116,15 +112,7 @@ export default function HomePage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!loading && loadError && (
|
||||
<div role="alert" className="rounded-2xl border border-error/30 bg-error-bg p-6 text-error">
|
||||
<p className="font-bold">Your study spaces could not be loaded.</p>
|
||||
<p className="mt-1 text-sm">{loadError}</p>
|
||||
<button onClick={() => void fetchClasses()} className="mt-4 min-h-10 rounded-lg border border-error/40 px-4 text-sm font-bold">Retry</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !loadError && classes.length === 0 && (
|
||||
{!loading && classes.length === 0 && (
|
||||
<div className="paper-grid rounded-[2rem] border border-dashed border-border bg-bg-surface px-6 py-16 text-center">
|
||||
<div className="mx-auto mb-5 grid h-14 w-14 place-items-center rounded-2xl bg-bg-callout text-2xl text-primary">+</div>
|
||||
<h2 className="editorial-title text-2xl text-text-heading">Your desk is ready</h2>
|
||||
|
|
@ -133,7 +121,7 @@ export default function HomePage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !loadError && classes.length > 0 && (
|
||||
{!loading && classes.length > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{classes.map((cls, index) => (
|
||||
<article key={cls.id} className="group relative overflow-hidden rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)] transition-all hover:-translate-y-1 hover:shadow-[var(--shadow-card-hover)]">
|
||||
|
|
@ -149,14 +137,6 @@ export default function HomePage() {
|
|||
<div className="mt-3 flex flex-wrap gap-2 text-xs font-semibold text-text-secondary">
|
||||
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1">{cls._count.decks} {cls._count.decks === 1 ? "deck" : "decks"}</span>
|
||||
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1">{cls._count.quizSets} {cls._count.quizSets === 1 ? "quiz" : "quizzes"}</span>
|
||||
{cls.spacedRepetitionReadyCards > 0 && (
|
||||
<span
|
||||
className="rounded-full bg-error-bg px-2.5 py-1 text-error"
|
||||
title={`${cls.spacedRepetitionDueCards} due, ${cls.spacedRepetitionLearningCards} learning, ${cls.spacedRepetitionNewCards} new`}
|
||||
>
|
||||
{cls.spacedRepetitionReadyCards} {cls.spacedRepetitionReadyCards === 1 ? "card" : "cards"} ready
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="absolute right-3 top-4 flex gap-1 rounded-xl border border-border-light bg-bg-surface p-1 opacity-100 shadow-sm sm:opacity-0 sm:group-hover:opacity-100">
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { studyActivitySchema } from "@/lib/validation/activitySchemas";
|
||||
import * as activityService from "@/services/activityService";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(await activityService.getActivitySummary());
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const parsed = studyActivitySchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid activity type" }, { status: 400 });
|
||||
}
|
||||
|
||||
await activityService.recordActivity(parsed.data.type);
|
||||
return NextResponse.json({ success: true }, { status: 201 });
|
||||
}
|
||||
|
|
@ -1,28 +1,116 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { loginSchema } from "@/lib/validation/authSchemas";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createSession } from "@/lib/auth";
|
||||
import { checkRateLimit } from "@/lib/rateLimiter";
|
||||
import { login } from "@/services/authService";
|
||||
|
||||
// Progressive lockout thresholds from Section 4
|
||||
function getLockoutDuration(failedAttempts: number): number {
|
||||
if (failedAttempts >= 20) return 24 * 60 * 60 * 1000; // 24 hours
|
||||
if (failedAttempts >= 15) return 30 * 60 * 1000; // 30 minutes
|
||||
if (failedAttempts >= 10) return 5 * 60 * 1000; // 5 minutes
|
||||
if (failedAttempts >= 5) return 1 * 60 * 1000; // 1 minute
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!checkRateLimit("login:global").allowed) {
|
||||
// Rate limit by IP
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
const ip = forwarded?.split(",")[0]?.trim() ?? "unknown";
|
||||
const rateCheck = checkRateLimit(ip);
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests. Try again shortly." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = loginSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body?.password || typeof body.password !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid request" },
|
||||
{ error: "Password is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await login(parsed.data.password);
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ error: result.error }, { status: result.status });
|
||||
// Check lockout state
|
||||
let security = await prisma.authSecurity.findUnique({ where: { id: 1 } });
|
||||
if (!security) {
|
||||
security = await prisma.authSecurity.create({ data: { id: 1 } });
|
||||
}
|
||||
|
||||
if (security.lockedUntil && security.lockedUntil > new Date()) {
|
||||
const remainingMs =
|
||||
security.lockedUntil.getTime() - Date.now();
|
||||
const remainingMin = Math.ceil(remainingMs / 60000);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Account locked. Try again in ${remainingMin} minute${remainingMin !== 1 ? "s" : ""}.`,
|
||||
},
|
||||
{ status: 423 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const setting = await prisma.setting.findUnique({
|
||||
where: { key: "admin_password_hash" },
|
||||
});
|
||||
|
||||
let isValid = false;
|
||||
|
||||
if (!setting) {
|
||||
// Initial setup: hash and save the new password
|
||||
const argon2 = await import("argon2");
|
||||
const newHash = await argon2.hash(body.password);
|
||||
await prisma.setting.create({
|
||||
data: {
|
||||
key: "admin_password_hash",
|
||||
value: newHash,
|
||||
},
|
||||
});
|
||||
isValid = true;
|
||||
} else {
|
||||
// Verify existing password
|
||||
const passwordHash = setting.value;
|
||||
try {
|
||||
const argon2 = await import("argon2");
|
||||
isValid = await argon2.verify(passwordHash, body.password);
|
||||
} catch {
|
||||
isValid = body.password === passwordHash;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
const newFailedAttempts = security.failedAttempts + 1;
|
||||
const lockoutMs = getLockoutDuration(newFailedAttempts);
|
||||
const lockedUntil = lockoutMs > 0 ? new Date(Date.now() + lockoutMs) : null;
|
||||
|
||||
await prisma.authSecurity.update({
|
||||
where: { id: 1 },
|
||||
data: {
|
||||
failedAttempts: newFailedAttempts,
|
||||
lockedUntil,
|
||||
lastAttemptAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid password" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Success — reset lockout, create session
|
||||
await prisma.authSecurity.update({
|
||||
where: { id: 1 },
|
||||
data: {
|
||||
failedAttempts: 0,
|
||||
lockedUntil: null,
|
||||
lastAttemptAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await createSession();
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { completePasswordResetSchema } from "@/lib/validation/authSchemas";
|
||||
import { checkRateLimit } from "@/lib/rateLimiter";
|
||||
import { completePasswordReset } from "@/services/authService";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (
|
||||
!checkRateLimit("password-reset-complete:global", {
|
||||
windowMs: 60_000,
|
||||
maxRequests: 5,
|
||||
}).allowed
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many attempts. Try again shortly." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
const parsed = completePasswordResetSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid request" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await completePasswordReset(parsed.data.password))) {
|
||||
return NextResponse.json(
|
||||
{ error: "Reset authorization is invalid or expired" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { checkRateLimit } from "@/lib/rateLimiter";
|
||||
import { requestPasswordReset } from "@/services/authService";
|
||||
|
||||
export async function POST() {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return NextResponse.json(
|
||||
{ error: "Start password recovery from the local server console." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const rateLimit = checkRateLimit("password-reset-request:global", {
|
||||
windowMs: 15 * 60_000,
|
||||
maxRequests: 1,
|
||||
});
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: "A reset token was requested recently. Try again shortly." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await requestPasswordReset();
|
||||
if (result.status === "missing-password") {
|
||||
return NextResponse.json(
|
||||
{ error: "Password recovery is unavailable before initial setup." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
if (result.status === "active-token") {
|
||||
return NextResponse.json(
|
||||
{ error: "An unexpired reset token already exists." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ success: true, token: result.token });
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { checkRateLimit } from "@/lib/rateLimiter";
|
||||
import { resetTokenSchema } from "@/lib/validation/authSchemas";
|
||||
import { verifyPasswordResetToken } from "@/services/authService";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!checkRateLimit("password-reset-verify:global").allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many attempts. Try again shortly." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = resetTokenSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid or expired reset token" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!(await verifyPasswordResetToken(parsed.data.token))) {
|
||||
return NextResponse.json({ error: "Invalid or expired reset token" }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getSetupStatus } from "@/services/authService";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return NextResponse.json(await getSetupStatus());
|
||||
const setting = await prisma.setting.findUnique({
|
||||
where: { key: "admin_password_hash" },
|
||||
});
|
||||
|
||||
return NextResponse.json({ setupRequired: !setting });
|
||||
} catch (error) {
|
||||
console.error("Failed to check setup status:", error);
|
||||
return NextResponse.json(
|
||||
|
|
|
|||
|
|
@ -1,25 +1,22 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as cardService from "@/services/cardService";
|
||||
import { cardUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas";
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
ctx: RouteContext<"/api/cards/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const parsed = cardUpdateSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid card update", details: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
try {
|
||||
return NextResponse.json(await cardService.updateCard(id, parsed.data));
|
||||
} catch (error) {
|
||||
if (isPrismaError(error, "P2025")) {
|
||||
const updated = await cardService.updateCard(id, {
|
||||
front: body?.front?.trim(),
|
||||
back: body?.back?.trim(),
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
||||
}
|
||||
console.error("Failed to update card", error);
|
||||
return NextResponse.json({ error: "Failed to update card" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
|
|
@ -27,14 +24,11 @@ export async function DELETE(
|
|||
ctx: RouteContext<"/api/cards/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
|
||||
try {
|
||||
await cardService.deleteCard(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (isPrismaError(error, "P2025")) {
|
||||
} catch {
|
||||
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 * as classService from "@/services/classService";
|
||||
import { classUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas";
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
ctx: RouteContext<"/api/classes/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const parsed = classUpdateSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "A valid class name is required" }, { status: 400 });
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body || (!body.name && body.name !== "")) {
|
||||
return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
return NextResponse.json(await classService.updateClass(id, parsed.data));
|
||||
} catch (error) {
|
||||
if (isPrismaError(error, "P2025")) {
|
||||
const updated = await classService.updateClass(id, {
|
||||
name: body.name?.trim(),
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
} catch {
|
||||
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(
|
||||
|
|
@ -27,14 +27,11 @@ export async function DELETE(
|
|||
ctx: RouteContext<"/api/classes/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
|
||||
try {
|
||||
await classService.deleteClass(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (isPrismaError(error, "P2025")) {
|
||||
} catch {
|
||||
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,23 +1,20 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as classService from "@/services/classService";
|
||||
import { classCreateSchema, isPrismaError } from "@/lib/validation/contentSchemas";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(await classService.listClasses());
|
||||
const classes = await classService.listClasses();
|
||||
return NextResponse.json(classes);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const parsed = classCreateSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Class name is required and must be 160 characters or fewer" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
return NextResponse.json(await classService.createClass(parsed.data.name), { status: 201 });
|
||||
} catch (error) {
|
||||
if (isPrismaError(error, "P2002")) {
|
||||
return NextResponse.json({ error: "A class with that slug already exists" }, { status: 409 });
|
||||
}
|
||||
console.error("Failed to create class", error);
|
||||
return NextResponse.json({ error: "Failed to create class" }, { status: 500 });
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body?.name || typeof body.name !== "string" || !body.name.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Class name is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const newClass = await classService.createClass(body.name.trim());
|
||||
return NextResponse.json(newClass, { status: 201 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,29 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as cardService from "@/services/cardService";
|
||||
import { cardContentSchema, isPrismaError } from "@/lib/validation/contentSchemas";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
ctx: RouteContext<"/api/decks/[id]/cards">
|
||||
) {
|
||||
const { id: deckId } = await ctx.params;
|
||||
const parsed = cardContentSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (
|
||||
!body?.front ||
|
||||
!body?.back ||
|
||||
typeof body.front !== "string" ||
|
||||
typeof body.back !== "string"
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Card front and back are required and must be within the content limit", details: parsed.error.issues },
|
||||
{ error: "front and back are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
return NextResponse.json(await cardService.createCard(deckId, parsed.data), { status: 201 });
|
||||
} catch (error) {
|
||||
if (isPrismaError(error, "P2003")) {
|
||||
return NextResponse.json({ error: "Deck not found" }, { status: 404 });
|
||||
}
|
||||
console.error("Failed to create card", error);
|
||||
return NextResponse.json({ error: "Failed to create card" }, { status: 500 });
|
||||
}
|
||||
|
||||
const card = await cardService.createCard(deckId, {
|
||||
front: body.front.trim(),
|
||||
back: body.back.trim(),
|
||||
});
|
||||
|
||||
return NextResponse.json(card, { status: 201 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as deckService from "@/services/deckService";
|
||||
import { contentUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
|
|
@ -21,22 +20,17 @@ export async function PATCH(
|
|||
ctx: RouteContext<"/api/decks/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const parsed = contentUpdateSchema.safeParse(await request.json().catch(() => null));
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid deck update", details: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
try {
|
||||
const updated = await deckService.updateDeck(id, parsed.data);
|
||||
const updated = await deckService.updateDeck(id, {
|
||||
name: body?.name?.trim(),
|
||||
description: body?.description,
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
if (isPrismaError(error, "P2025")) {
|
||||
} catch {
|
||||
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(
|
||||
|
|
@ -48,11 +42,7 @@ export async function DELETE(
|
|||
try {
|
||||
await deckService.deleteDeck(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (isPrismaError(error, "P2025")) {
|
||||
} catch {
|
||||
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,29 +1,31 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { reorderRequestSchema } from "@/lib/validation/reorderSchemas";
|
||||
import {
|
||||
ReorderConflictError,
|
||||
ReorderValidationError,
|
||||
reorderContent,
|
||||
} from "@/services/reorderService";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const parsed = reorderRequestSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid reorder input" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
await reorderContent("DECK", parsed.data);
|
||||
const body = await request.json();
|
||||
const { items } = body;
|
||||
|
||||
if (!Array.isArray(items)) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
// items should be an array of { id, sortOrder, groupId }
|
||||
// Using a transaction to perform all updates
|
||||
await prisma.$transaction(
|
||||
items.map((item: any) =>
|
||||
prisma.deck.update({
|
||||
where: { id: item.id },
|
||||
data: {
|
||||
sortOrder: item.sortOrder,
|
||||
groupId: item.groupId,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof ReorderValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
if (error instanceof ReorderConflictError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 409 });
|
||||
}
|
||||
console.error("Failed to reorder decks", error);
|
||||
return NextResponse.json({ error: "Failed to reorder decks" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,40 +1,35 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as deckService from "@/services/deckService";
|
||||
import { deckImportRequestSchema } from "@/lib/validation/importSchemas";
|
||||
import { readLimitedJson, RequestTooLargeError } from "@/lib/limitedJson";
|
||||
import { flashcardImportSchema } from "@/lib/validation/importSchemas";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await readLimitedJson(request);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestTooLargeError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 413 });
|
||||
}
|
||||
throw error;
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = deckImportRequestSchema.safeParse(body);
|
||||
const { classId, data, name, groupId } = body;
|
||||
|
||||
if (!classId || typeof classId !== "string") {
|
||||
return NextResponse.json({ error: "classId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Server-side validation — never trust client
|
||||
const parsed = flashcardImportSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid deck import", details: parsed.error.issues },
|
||||
{ error: "Validation failed", details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const deck = await deckService.createDeckFromImport(
|
||||
parsed.data.classId,
|
||||
parsed.data.data,
|
||||
parsed.data.name,
|
||||
parsed.data.groupId ?? null
|
||||
classId,
|
||||
parsed.data,
|
||||
name?.trim() || undefined,
|
||||
groupId || null
|
||||
);
|
||||
|
||||
return NextResponse.json(deck, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof deckService.DeckValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
console.error("Failed to create deck", error);
|
||||
return NextResponse.json({ error: "Failed to create deck" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { checkApplicationHealth } from "@/services/healthService";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await checkApplicationHealth();
|
||||
return NextResponse.json({ status: "ok" });
|
||||
} catch (error) {
|
||||
console.error("Health check failed: database schema is unavailable", error);
|
||||
return NextResponse.json({ status: "unhealthy" }, { status: 503 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +1,41 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { materialGroupUpdateSchema } from "@/lib/validation/materialGroupSchemas";
|
||||
import {
|
||||
deleteMaterialGroup,
|
||||
renameMaterialGroup,
|
||||
} from "@/services/materialGroupService";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const parsed = materialGroupUpdateSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid group update" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
return NextResponse.json(await renameMaterialGroup(id, parsed.data.name));
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { name, sortOrder } = body;
|
||||
|
||||
const updateData: any = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (sortOrder !== undefined) updateData.sortOrder = sortOrder;
|
||||
|
||||
const group = await prisma.materialGroup.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return NextResponse.json(group);
|
||||
} catch (error) {
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
error.code === "P2025"
|
||||
) {
|
||||
return NextResponse.json({ error: "Group not found" }, { status: 404 });
|
||||
}
|
||||
console.error("Failed to rename material group", error);
|
||||
return NextResponse.json({ error: "Failed to rename group" }, { status: 500 });
|
||||
return NextResponse.json({ error: "Failed to update material group" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
if (!(await deleteMaterialGroup(id))) {
|
||||
return NextResponse.json({ error: "Group not found" }, { status: 404 });
|
||||
}
|
||||
await prisma.materialGroup.delete({
|
||||
where: { id },
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to delete material group" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,56 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
materialGroupCreateSchema,
|
||||
materialGroupQuerySchema,
|
||||
} from "@/lib/validation/materialGroupSchemas";
|
||||
import {
|
||||
createMaterialGroup,
|
||||
listMaterialGroups,
|
||||
} from "@/services/materialGroupService";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const parsed = materialGroupQuerySchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid group query" }, { status: 400 });
|
||||
const { searchParams } = new URL(request.url);
|
||||
const classId = searchParams.get("classId");
|
||||
const type = searchParams.get("type");
|
||||
|
||||
if (!classId) {
|
||||
return NextResponse.json({ error: "classId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (type && type !== "DECK" && type !== "QUIZ") {
|
||||
return NextResponse.json({ error: "Invalid type" }, { status: 400 });
|
||||
}
|
||||
|
||||
const whereClause: any = { classId };
|
||||
if (type) {
|
||||
whereClause.type = type;
|
||||
}
|
||||
|
||||
try {
|
||||
const groups = await prisma.materialGroup.findMany({
|
||||
where: whereClause,
|
||||
orderBy: { sortOrder: "asc" },
|
||||
});
|
||||
return NextResponse.json(groups);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to fetch material groups" }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
await listMaterialGroups(parsed.data.classId, parsed.data.type)
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const parsed = materialGroupCreateSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid group" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const group = await createMaterialGroup(parsed.data);
|
||||
if (!group) {
|
||||
return NextResponse.json({ error: "Class not found" }, { status: 404 });
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { classId, name, type } = body;
|
||||
|
||||
if (!classId || !name || (type !== "DECK" && type !== "QUIZ")) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const maxOrder = await prisma.materialGroup.aggregate({
|
||||
where: { classId, type },
|
||||
_max: { sortOrder: true },
|
||||
});
|
||||
const sortOrder = (maxOrder._max.sortOrder ?? -1) + 1;
|
||||
|
||||
const group = await prisma.materialGroup.create({
|
||||
data: { classId, name, type, sortOrder },
|
||||
});
|
||||
|
||||
return NextResponse.json(group, { status: 201 });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to create material group" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,64 +1,66 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
progressDeleteSchema,
|
||||
progressPatchSchema,
|
||||
progressQuerySchema,
|
||||
} from "@/lib/validation/progressSchemas";
|
||||
import {
|
||||
ProgressConflictError,
|
||||
ProgressNotFoundError,
|
||||
ProgressValidationError,
|
||||
clearProgress,
|
||||
getProgress,
|
||||
saveProgress,
|
||||
} from "@/services/progressService";
|
||||
import * as progressService from "@/services/progressService";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const parsed = progressQuerySchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams)
|
||||
const { searchParams } = new URL(request.url);
|
||||
const contentType = searchParams.get("contentType") as "DECK" | "QUIZ";
|
||||
const contentId = searchParams.get("contentId");
|
||||
const mode = searchParams.get("mode") as "SEQUENTIAL" | "SHUFFLED";
|
||||
|
||||
if (!contentType || !contentId || !mode) {
|
||||
return NextResponse.json(
|
||||
{ error: "contentType, contentId, and mode are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
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) {
|
||||
const parsed = progressPatchSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body?.contentType || !body?.contentId || !body?.mode) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid progress" },
|
||||
{ error: "contentType, contentId, and mode are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
return NextResponse.json(await saveProgress(parsed.data));
|
||||
} catch (error) {
|
||||
if (error instanceof ProgressNotFoundError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
if (error instanceof ProgressConflictError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 409 });
|
||||
}
|
||||
if (error instanceof ProgressValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
console.error("Failed to save study progress", error);
|
||||
return NextResponse.json({ error: "Failed to save progress" }, { status: 500 });
|
||||
}
|
||||
|
||||
const progress = await progressService.upsertProgress({
|
||||
contentType: body.contentType,
|
||||
contentId: body.contentId,
|
||||
mode: body.mode,
|
||||
currentIndex: body.currentIndex ?? 0,
|
||||
orderJson: body.orderJson,
|
||||
answersJson: body.answersJson,
|
||||
cardResultsJson: body.cardResultsJson,
|
||||
});
|
||||
|
||||
return NextResponse.json(progress);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const parsed = progressDeleteSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body?.contentType || !body?.contentId || !body?.mode) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid progress deletion" },
|
||||
{ error: "contentType, contentId, and mode are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ cleared: await clearProgress(parsed.data) });
|
||||
|
||||
await progressService.clearProgress(
|
||||
body.contentType,
|
||||
body.contentId,
|
||||
body.mode
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,40 +1,65 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
QuizAttemptValidationError,
|
||||
QuizNotFoundError,
|
||||
listQuizAttempts,
|
||||
submitQuizAttempt,
|
||||
} from "@/services/quizService";
|
||||
import { quizAttemptSchema } from "@/lib/validation/attemptSchemas";
|
||||
import { getQuizSetWithQuestions, createQuizAttempt, listQuizAttempts } from "@/services/quizService";
|
||||
import { scoreQuiz } from "@/lib/scoring";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
ctx: RouteContext<"/api/quizzes/[id]/attempt">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const parsed = quizAttemptSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body || !body.answersJson) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid attempt" },
|
||||
{ error: "answersJson is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const quizSet = await getQuizSetWithQuestions(id);
|
||||
if (!quizSet) {
|
||||
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Parse answers
|
||||
let answers: Record<string, string[]>;
|
||||
try {
|
||||
return NextResponse.json(await submitQuizAttempt(id, parsed.data), {
|
||||
status: 201,
|
||||
answers = JSON.parse(body.answersJson);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid answers JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Determine which questions were included in this attempt
|
||||
const isPartialRetake = body.isPartialRetake === true;
|
||||
|
||||
// If partial retake, we only score the questions that actually had answers provided
|
||||
// or that were explicitly passed in a questionIds array.
|
||||
// For simplicity, we filter the quizSet questions down to what's in the answers object
|
||||
// if it's a partial retake, though the client will only show those anyway.
|
||||
let questionsToScore = quizSet.questions;
|
||||
if (isPartialRetake) {
|
||||
const answeredIds = Object.keys(answers);
|
||||
questionsToScore = quizSet.questions.filter(q => answeredIds.includes(q.id));
|
||||
}
|
||||
|
||||
// Format questions for the scoring utility
|
||||
const formattedQuestions = questionsToScore.map((q) => ({
|
||||
id: q.id,
|
||||
type: q.type as "MULTIPLE_CHOICE" | "SATA",
|
||||
options: q.options.map((o) => ({ id: o.id, isCorrect: o.isCorrect })),
|
||||
}));
|
||||
|
||||
const { total, maxScore } = scoreQuiz(formattedQuestions, answers);
|
||||
|
||||
const attempt = await createQuizAttempt({
|
||||
quizSetId: id,
|
||||
score: total,
|
||||
maxScore: maxScore,
|
||||
answersJson: body.answersJson,
|
||||
isPartialRetake,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof QuizNotFoundError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
if (error instanceof QuizAttemptValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
console.error("Failed to submit quiz attempt", error);
|
||||
return NextResponse.json({ error: "Failed to submit attempt" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json(attempt, { status: 201 });
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as quizService from "@/services/quizService";
|
||||
import { contentUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
|
|
@ -21,22 +20,17 @@ export async function PATCH(
|
|||
ctx: RouteContext<"/api/quizzes/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const parsed = contentUpdateSchema.safeParse(await request.json().catch(() => null));
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid quiz update", details: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
try {
|
||||
const updated = await quizService.updateQuizSet(id, parsed.data);
|
||||
const updated = await quizService.updateQuizSet(id, {
|
||||
name: body?.name?.trim(),
|
||||
description: body?.description,
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
if (isPrismaError(error, "P2025")) {
|
||||
} catch {
|
||||
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(
|
||||
|
|
@ -48,11 +42,7 @@ export async function DELETE(
|
|||
try {
|
||||
await quizService.deleteQuizSet(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (isPrismaError(error, "P2025")) {
|
||||
} catch {
|
||||
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,29 +1,31 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { reorderRequestSchema } from "@/lib/validation/reorderSchemas";
|
||||
import {
|
||||
ReorderConflictError,
|
||||
ReorderValidationError,
|
||||
reorderContent,
|
||||
} from "@/services/reorderService";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const parsed = reorderRequestSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid reorder input" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
await reorderContent("QUIZ", parsed.data);
|
||||
const body = await request.json();
|
||||
const { items } = body;
|
||||
|
||||
if (!Array.isArray(items)) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
// items should be an array of { id, sortOrder, groupId }
|
||||
// Using a transaction to perform all updates
|
||||
await prisma.$transaction(
|
||||
items.map((item: any) =>
|
||||
prisma.quizSet.update({
|
||||
where: { id: item.id },
|
||||
data: {
|
||||
sortOrder: item.sortOrder,
|
||||
groupId: item.groupId,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof ReorderValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
if (error instanceof ReorderConflictError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 409 });
|
||||
}
|
||||
console.error("Failed to reorder quizzes", error);
|
||||
return NextResponse.json({ error: "Failed to reorder quizzes" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,35 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as quizService from "@/services/quizService";
|
||||
import { quizImportRequestSchema } from "@/lib/validation/importSchemas";
|
||||
import { readLimitedJson, RequestTooLargeError } from "@/lib/limitedJson";
|
||||
import { quizImportSchema } from "@/lib/validation/importSchemas";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await readLimitedJson(request);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestTooLargeError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 413 });
|
||||
}
|
||||
throw error;
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = quizImportRequestSchema.safeParse(body);
|
||||
const { classId, data, name, groupId } = body;
|
||||
|
||||
if (!classId || typeof classId !== "string") {
|
||||
return NextResponse.json({ error: "classId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Server-side validation
|
||||
const parsed = quizImportSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid quiz import. SATA questions need at least two correct options.",
|
||||
details: parsed.error.issues,
|
||||
},
|
||||
{ error: "Validation failed", details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const quiz = await quizService.createQuizSetFromImport(
|
||||
parsed.data.classId,
|
||||
parsed.data.data,
|
||||
parsed.data.name,
|
||||
parsed.data.groupId ?? null
|
||||
const quizSet = await quizService.createQuizSetFromImport(
|
||||
classId,
|
||||
parsed.data,
|
||||
name?.trim() || undefined,
|
||||
groupId || null
|
||||
);
|
||||
return NextResponse.json(quiz, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof quizService.QuizContentValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
console.error("Failed to create quiz", error);
|
||||
return NextResponse.json({ error: "Failed to create quiz" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json(quizSet, { status: 201 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,16 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as settingsService from "@/services/settingsService";
|
||||
|
||||
function getInstructionType(request: NextRequest): settingsService.LlmInstructionType | null {
|
||||
const type = new URL(request.url).searchParams.get("type") ?? "flashcards";
|
||||
return type === "flashcards" || type === "quizzes" ? type : null;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const type = getInstructionType(request);
|
||||
if (!type) return NextResponse.json({ error: "Invalid instruction type" }, { status: 400 });
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
|
||||
const instructions = await settingsService.getLlmInstructions(type);
|
||||
return NextResponse.json({ value: instructions });
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const type = getInstructionType(request);
|
||||
if (!type) return NextResponse.json({ error: "Invalid instruction type" }, { status: 400 });
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body || typeof body.value !== "string") {
|
||||
|
|
|
|||
|
|
@ -1,21 +1,20 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { shareTargetSchema } from "@/lib/validation/shareSchemas";
|
||||
import {
|
||||
ShareTargetNotFoundError,
|
||||
getShareLinkForContent,
|
||||
isContentSharedViaGroup,
|
||||
toggleShareLink,
|
||||
} from "@/services/shareService";
|
||||
import { toggleShareLink, getShareLinkForContent, isContentSharedViaGroup } from "@/services/shareService";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const parsed = shareTargetSchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams)
|
||||
const { searchParams } = new URL(request.url);
|
||||
const targetType = searchParams.get("targetType") as "DECK" | "QUIZ" | "GROUP";
|
||||
const contentId = searchParams.get("contentId");
|
||||
|
||||
if (!targetType || !contentId) {
|
||||
return NextResponse.json(
|
||||
{ error: "targetType and contentId are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid share target" }, { status: 400 });
|
||||
}
|
||||
const { targetType, contentId } = parsed.data;
|
||||
|
||||
const link = await getShareLinkForContent(targetType, contentId);
|
||||
|
||||
if (targetType === "DECK" || targetType === "QUIZ") {
|
||||
const groupShare = await isContentSharedViaGroup(targetType, contentId);
|
||||
if (groupShare) {
|
||||
|
|
@ -26,27 +25,20 @@ export async function GET(request: NextRequest) {
|
|||
});
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ token: link?.id ?? null, isGroupShared: false });
|
||||
|
||||
return NextResponse.json({ token: link?.id || null, isGroupShared: false });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const parsed = shareTargetSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
const body = 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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi";
|
||||
import * as spacedRepetitionService from "@/services/spacedRepetitionService";
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
context: { params: Promise<{ id: string; deckId: string }> }
|
||||
) {
|
||||
const { id, deckId } = await context.params;
|
||||
try {
|
||||
await spacedRepetitionService.removeDeck(id, deckId);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return spacedRepetitionErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
addSpacedRepetitionDeckSchema,
|
||||
reorderSpacedRepetitionDecksSchema,
|
||||
} from "@/lib/validation/spacedRepetitionSchemas";
|
||||
import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi";
|
||||
import * as spacedRepetitionService from "@/services/spacedRepetitionService";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const parsed = addSpacedRepetitionDeckSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Invalid deck" }, { status: 400 });
|
||||
try {
|
||||
return NextResponse.json(
|
||||
await spacedRepetitionService.addDeck(id, parsed.data.deckId),
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
return spacedRepetitionErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const parsed = reorderSpacedRepetitionDecksSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Invalid deck order" }, { status: 400 });
|
||||
try {
|
||||
await spacedRepetitionService.reorderDecks(id, parsed.data.deckIds);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return spacedRepetitionErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { reviewSpacedRepetitionCardSchema } from "@/lib/validation/spacedRepetitionSchemas";
|
||||
import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi";
|
||||
import * as spacedRepetitionService from "@/services/spacedRepetitionService";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const parsed = reviewSpacedRepetitionCardSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Invalid review" }, { status: 400 });
|
||||
try {
|
||||
return NextResponse.json(
|
||||
await spacedRepetitionService.reviewCard({ setId: id, ...parsed.data })
|
||||
);
|
||||
} catch (error) {
|
||||
return spacedRepetitionErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { updateSpacedRepetitionSetSchema } from "@/lib/validation/spacedRepetitionSchemas";
|
||||
import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi";
|
||||
import * as spacedRepetitionService from "@/services/spacedRepetitionService";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
try {
|
||||
return NextResponse.json(await spacedRepetitionService.getSet(id));
|
||||
} catch (error) {
|
||||
return spacedRepetitionErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const parsed = updateSpacedRepetitionSetSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Invalid update" }, { status: 400 });
|
||||
try {
|
||||
return NextResponse.json(await spacedRepetitionService.updateSet(id, parsed.data));
|
||||
} catch (error) {
|
||||
return spacedRepetitionErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
try {
|
||||
await spacedRepetitionService.deleteSet(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return spacedRepetitionErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi";
|
||||
import * as spacedRepetitionService from "@/services/spacedRepetitionService";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
try {
|
||||
return NextResponse.json(await spacedRepetitionService.getStudyState(id));
|
||||
} catch (error) {
|
||||
return spacedRepetitionErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createSpacedRepetitionSetSchema } from "@/lib/validation/spacedRepetitionSchemas";
|
||||
import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi";
|
||||
import * as spacedRepetitionService from "@/services/spacedRepetitionService";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const classId = new URL(request.url).searchParams.get("classId");
|
||||
if (!classId) return NextResponse.json({ error: "classId is required" }, { status: 400 });
|
||||
try {
|
||||
return NextResponse.json(await spacedRepetitionService.listSetsByClass(classId));
|
||||
} catch (error) {
|
||||
return spacedRepetitionErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const parsed = createSpacedRepetitionSetSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid repetition set" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
return NextResponse.json(await spacedRepetitionService.createSet(parsed.data), {
|
||||
status: 201,
|
||||
});
|
||||
} catch (error) {
|
||||
return spacedRepetitionErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--theme-bg-base: #f5f2ea;
|
||||
|
|
@ -172,65 +170,6 @@
|
|||
.animate-slide-up { animation: slideUp .32s var(--ease-spring); }
|
||||
.animate-subtle-pulse { animation: subtlePulse 1.7s ease-in-out infinite; }
|
||||
|
||||
/* Flashcard streaks. Flame colours are deliberately theme-independent: the same
|
||||
yellow-to-orange ramp reads correctly on both the light and dark surfaces. */
|
||||
:root {
|
||||
--flame-hot: #fbbf24;
|
||||
--flame-mid: #fb923c;
|
||||
--flame-core: #ea580c;
|
||||
--flame-ink: #451a03;
|
||||
}
|
||||
|
||||
@keyframes streakBarFlow { from { background-position: 0% 50%; } to { background-position: -200% 50%; } }
|
||||
@keyframes streakChipIn { from { opacity: 0; transform: translateY(-4px) scale(.9); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
@keyframes streakFlicker {
|
||||
0%, 100% { transform: scale(1) rotate(0); }
|
||||
25% { transform: scale(1.07) rotate(-2.5deg); }
|
||||
60% { transform: scale(.96) rotate(2deg); }
|
||||
}
|
||||
@keyframes streakPop {
|
||||
0% { opacity: 0; transform: translateY(26px) scale(.55); }
|
||||
28% { opacity: 1; transform: translateY(-2px) scale(1.1); }
|
||||
44% { transform: translateY(0) scale(1); }
|
||||
74% { opacity: 1; transform: translateY(-5px) scale(1); }
|
||||
100% { opacity: 0; transform: translateY(-38px) scale(.9); }
|
||||
}
|
||||
|
||||
.streak-bar-flame {
|
||||
background-image: linear-gradient(100deg, var(--flame-hot), var(--flame-mid) 35%, var(--flame-core) 55%, var(--flame-mid) 75%, var(--flame-hot));
|
||||
background-size: 200% 100%;
|
||||
animation: streakBarFlow 2.6s linear infinite;
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--flame-core) 55%, transparent);
|
||||
}
|
||||
|
||||
.streak-chip { animation: streakChipIn .28s var(--ease-spring); }
|
||||
.streak-chip-flame {
|
||||
color: var(--flame-ink);
|
||||
background-image: linear-gradient(100deg, var(--flame-hot), var(--flame-core));
|
||||
box-shadow: 0 2px 12px color-mix(in srgb, var(--flame-core) 40%, transparent);
|
||||
}
|
||||
|
||||
/* Softens the card behind a burst so the milestone stays readable. */
|
||||
@keyframes streakScrim { 0% { opacity: 0; } 14% { opacity: 1; } 76% { opacity: 1; } 100% { opacity: 0; } }
|
||||
.streak-scrim {
|
||||
background: radial-gradient(
|
||||
circle at 50% 46%,
|
||||
color-mix(in srgb, var(--color-bg-surface) 94%, transparent) 0%,
|
||||
color-mix(in srgb, var(--color-bg-surface) 78%, transparent) 42%,
|
||||
transparent 76%
|
||||
);
|
||||
animation: streakScrim var(--streak-pop-duration, 1.8s) ease-out forwards;
|
||||
}
|
||||
|
||||
.streak-pop { animation: streakPop var(--streak-pop-duration, 1.8s) var(--ease-spring) forwards; }
|
||||
.streak-pop-flame { animation: streakFlicker .9s ease-in-out infinite; transform-origin: 50% 85%; }
|
||||
.streak-pop-heading {
|
||||
background-image: linear-gradient(180deg, var(--flame-hot), var(--flame-core));
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.markdown-content h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: .5rem; color: var(--color-text-heading); }
|
||||
.markdown-content h2 { font-size: 1.25rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }
|
||||
.markdown-content h3 { font-size: 1.125rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Manrope, Newsreader } from "next/font/google";
|
||||
import Script from "next/script";
|
||||
import "./globals.css";
|
||||
|
||||
const manrope = Manrope({
|
||||
|
|
@ -25,12 +26,10 @@ export default function RootLayout({
|
|||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className={`${manrope.variable} ${newsreader.variable} h-full`} data-scroll-behavior="smooth" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
id="theme-init"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
<html lang="en" className={`${manrope.variable} ${newsreader.variable} h-full`} suppressHydrationWarning>
|
||||
<body className="min-h-full flex flex-col font-sans antialiased">
|
||||
<Script id="theme-init" strategy="beforeInteractive">
|
||||
{`
|
||||
try {
|
||||
const savedTheme = localStorage.theme
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
|
@ -40,11 +39,8 @@ export default function RootLayout({
|
|||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
} catch (_) {}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-full flex flex-col font-sans antialiased" suppressHydrationWarning>
|
||||
`}
|
||||
</Script>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,354 +1,158 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
||||
|
||||
type LoginStage = "login" | "token" | "password" | "success";
|
||||
|
||||
async function postJson<T extends object = Record<string, never>>(path: string, body?: object): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
const data = await response.json() as T & { error?: string };
|
||||
if (!response.ok) throw new Error(data.error || "Request failed");
|
||||
return data;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [stage, setStage] = useState<LoginStage>("login");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [setupRequired, setSetupRequired] = useState<boolean | null>(null);
|
||||
const [setupAllowed, setSetupAllowed] = useState(false);
|
||||
const [statusError, setStatusError] = useState("");
|
||||
const [isSetup, setIsSetup] = useState<boolean | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetch("/api/auth/setup-status", { signal: controller.signal })
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error("Setup status could not be loaded.");
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setSetupRequired(Boolean(data.setupRequired));
|
||||
setSetupAllowed(Boolean(data.setupAllowed));
|
||||
})
|
||||
.catch((caught) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setStatusError(caught instanceof Error ? caught.message : "Setup status could not be loaded.");
|
||||
setSetupRequired(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
fetch("/api/auth/setup-status")
|
||||
.then((res) => res.json())
|
||||
.then((data) => setIsSetup(data.setupRequired))
|
||||
.catch(() => setIsSetup(false)); // fallback
|
||||
}, []);
|
||||
|
||||
async function run(action: () => Promise<void>) {
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await action();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Network error. Please try again.");
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Login failed");
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = "/";
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogin(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
void run(async () => {
|
||||
await postJson("/api/auth/login", { password });
|
||||
window.location.href = "/";
|
||||
});
|
||||
}
|
||||
|
||||
function beginReset() {
|
||||
void run(async () => {
|
||||
const response = await postJson<{ token: string }>("/api/auth/password-reset/request");
|
||||
setToken(response.token);
|
||||
setStage("token");
|
||||
});
|
||||
}
|
||||
|
||||
function handleToken(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
void run(async () => {
|
||||
await postJson("/api/auth/password-reset/verify", { token: token.trim() });
|
||||
setPassword("");
|
||||
setConfirmPassword("");
|
||||
setStage("password");
|
||||
});
|
||||
}
|
||||
|
||||
function handlePasswordReset(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
void run(async () => {
|
||||
await postJson("/api/auth/password-reset/complete", {
|
||||
password,
|
||||
confirmPassword,
|
||||
});
|
||||
setToken("");
|
||||
setPassword("");
|
||||
setConfirmPassword("");
|
||||
setStage("success");
|
||||
});
|
||||
}
|
||||
|
||||
function returnToLogin() {
|
||||
setStage("login");
|
||||
setError("");
|
||||
setToken("");
|
||||
setPassword("");
|
||||
setConfirmPassword("");
|
||||
}
|
||||
|
||||
if (setupRequired === null) {
|
||||
if (isSetup === null) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<div className="animate-pulse flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const title =
|
||||
stage === "token"
|
||||
? "Enter reset token"
|
||||
: stage === "password"
|
||||
? "Choose a new password"
|
||||
: stage === "success"
|
||||
? "Password updated"
|
||||
: setupRequired
|
||||
? "Welcome to Study Desk"
|
||||
: "Welcome back";
|
||||
|
||||
const description =
|
||||
stage === "token"
|
||||
? "Find the token in the Study Desk Docker logs. It expires after 15 minutes."
|
||||
: stage === "password"
|
||||
? "Use at least eight characters and enter the password twice."
|
||||
: stage === "success"
|
||||
? "Your old password and existing sessions are no longer valid."
|
||||
: setupRequired
|
||||
? "Please set your admin password for the first time."
|
||||
: "Open your notes, decks, and practice quizzes.";
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden p-4 sm:p-8">
|
||||
<div className="absolute inset-0 paper-grid opacity-70" />
|
||||
<div className="absolute -left-24 top-1/4 h-80 w-80 rounded-full bg-primary/15 blur-3xl" />
|
||||
<div className="absolute -right-24 bottom-0 h-72 w-72 rounded-full bg-accent/15 blur-3xl" />
|
||||
<div className="absolute right-4 top-4 z-10"><ThemeToggle /></div>
|
||||
|
||||
<main className="relative w-full max-w-md animate-fade-in">
|
||||
<header className="mb-7 text-center">
|
||||
<div className="relative w-full max-w-md animate-fade-in">
|
||||
{/* Logo / Title area */}
|
||||
<div className="mb-7 text-center">
|
||||
<div className="mb-4 inline-grid h-14 w-14 place-items-center rounded-2xl bg-primary text-xl font-black text-white shadow-[0_12px_30px_color-mix(in_srgb,var(--theme-primary)_30%,transparent)]">S</div>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.2em] text-primary">Personal workspace</p>
|
||||
<h1 className="editorial-title mt-2 text-4xl text-text-heading">{title}</h1>
|
||||
<p className="mt-1 text-sm text-text-muted">{description}</p>
|
||||
</header>
|
||||
<h1 className="editorial-title mt-2 text-4xl text-text-heading">
|
||||
{isSetup ? "Welcome to Study Desk" : "Welcome back"}
|
||||
</h1>
|
||||
<p className="text-text-muted mt-1 text-sm">
|
||||
{isSetup
|
||||
? "Please set your admin password for the first time."
|
||||
: "Open your notes, decks, and practice quizzes."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login card */}
|
||||
<div className="rounded-[2rem] border border-border-light bg-bg-surface/90 p-6 shadow-[var(--shadow-modal)] backdrop-blur sm:p-8">
|
||||
{stage === "login" && (
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
{setupRequired && !setupAllowed && (
|
||||
<div role="alert" className="rounded-lg bg-error-bg px-3 py-2.5 text-sm text-error">
|
||||
Initial setup is disabled. Provision ADMIN_PASSWORD_HASH or temporarily enable ALLOW_INITIAL_SETUP on the local server.
|
||||
</div>
|
||||
)}
|
||||
{statusError && <ErrorMessage message={statusError} />}
|
||||
<PasswordField
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-text-secondary mb-1.5"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
placeholder={setupRequired ? "Create a new password" : "Enter your password"}
|
||||
autoFocus
|
||||
/>
|
||||
<ErrorMessage message={error} />
|
||||
<PrimaryButton loading={loading} disabled={password.length < 8 || (setupRequired && !setupAllowed)}>
|
||||
{setupRequired ? "Save Password & Login" : "Sign In"}
|
||||
</PrimaryButton>
|
||||
{!setupRequired && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={beginReset}
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg py-2 text-sm font-semibold text-primary hover:text-primary-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:opacity-50"
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{stage === "token" && (
|
||||
<form onSubmit={handleToken} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="reset-token" className="mb-1.5 block text-sm font-medium text-text-secondary">Reset token</label>
|
||||
<input
|
||||
id="reset-token"
|
||||
value={token}
|
||||
onChange={(event) => setToken(event.target.value)}
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
className="min-h-12 w-full rounded-xl border border-border bg-bg-surface-alt/60 px-4 font-mono text-text-heading placeholder:text-text-muted focus:border-primary"
|
||||
placeholder="Paste the token from Docker logs"
|
||||
/>
|
||||
</div>
|
||||
<ErrorMessage message={error} />
|
||||
<PrimaryButton loading={loading} disabled={!token.trim()}>Validate Token</PrimaryButton>
|
||||
<SecondaryButton onClick={returnToLogin}>Back to sign in</SecondaryButton>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{stage === "password" && (
|
||||
<form onSubmit={handlePasswordReset} className="space-y-4">
|
||||
<div>
|
||||
<PasswordField
|
||||
id="new-password"
|
||||
label="New password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
describedBy="password-length-status"
|
||||
invalid={password.length < 8}
|
||||
autoFocus
|
||||
/>
|
||||
<ValidationMessage
|
||||
id="password-length-status"
|
||||
valid={password.length >= 8}
|
||||
validText="Password meets the length requirement."
|
||||
invalidText="Password must be at least 8 characters."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<PasswordField
|
||||
id="confirm-password"
|
||||
label="Confirm new password"
|
||||
value={confirmPassword}
|
||||
onChange={setConfirmPassword}
|
||||
describedBy={confirmPassword ? "password-match-status" : undefined}
|
||||
invalid={confirmPassword.length > 0 && confirmPassword !== password}
|
||||
/>
|
||||
{confirmPassword && (
|
||||
<ValidationMessage
|
||||
id="password-match-status"
|
||||
valid={confirmPassword === password}
|
||||
validText="Passwords match."
|
||||
invalidText="Passwords do not match."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ErrorMessage message={error} />
|
||||
<PrimaryButton
|
||||
loading={loading}
|
||||
disabled={password.length < 8 || confirmPassword !== password}
|
||||
>
|
||||
Set New Password
|
||||
</PrimaryButton>
|
||||
<SecondaryButton onClick={returnToLogin}>Cancel</SecondaryButton>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{stage === "success" && (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="rounded-xl bg-success-bg px-4 py-3 text-sm text-success" role="status">
|
||||
Your password has been reset successfully.
|
||||
</div>
|
||||
<PrimaryButton loading={false} onClick={returnToLogin}>Sign In</PrimaryButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
describedBy,
|
||||
invalid = false,
|
||||
autoFocus = false,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
describedBy?: string;
|
||||
invalid?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="mb-1.5 block text-sm font-medium text-text-secondary">{label}</label>
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete={id === "password" ? "current-password" : "new-password"}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
autoFocus={autoFocus}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={isSetup ? "Create a new password" : "Enter your password"}
|
||||
autoFocus
|
||||
className="min-h-12 w-full rounded-xl border border-border bg-bg-surface-alt/60 px-4 text-text-heading placeholder:text-text-muted focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ValidationMessage({ id, valid, validText, invalidText }: {
|
||||
id: string;
|
||||
valid: boolean;
|
||||
validText: string;
|
||||
invalidText: string;
|
||||
}) {
|
||||
return (
|
||||
<p
|
||||
id={id}
|
||||
aria-live="polite"
|
||||
className={`mt-1.5 text-sm ${valid ? "text-success" : "text-error"}`}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-error bg-error-bg rounded-lg px-3 py-2.5">
|
||||
<svg
|
||||
className="w-4 h-4 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{valid ? validText : invalidText}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
function ErrorMessage({ message }: { message: string }) {
|
||||
if (!message) return null;
|
||||
return <div role="alert" className="rounded-lg bg-error-bg px-3 py-2.5 text-sm text-error">{message}</div>;
|
||||
}
|
||||
|
||||
function PrimaryButton({ loading, disabled = false, onClick, children }: {
|
||||
loading: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type={onClick ? "button" : "submit"}
|
||||
onClick={onClick}
|
||||
disabled={loading || disabled}
|
||||
className="min-h-12 w-full rounded-xl bg-primary px-4 font-bold text-white transition-colors hover:bg-primary-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="submit"
|
||||
disabled={loading || !password}
|
||||
className="min-h-12 w-full rounded-xl bg-primary px-4 font-bold text-white transition-colors hover:bg-primary-hover disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Working..." : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SecondaryButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className="min-h-11 w-full rounded-xl border border-border px-4 font-semibold text-text-secondary hover:bg-bg-surface-alt focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary">
|
||||
{children}
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
{isSetup ? "Saving..." : "Signing in..."}
|
||||
</span>
|
||||
) : isSetup ? (
|
||||
"Save Password & Login"
|
||||
) : (
|
||||
"Sign In"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@ import { useState, useEffect } from "react";
|
|||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { SharedGroupData } from "@/types/study";
|
||||
|
||||
export function SharedGroupViewer({ data, token }: { data: SharedGroupData; token: string }) {
|
||||
export function SharedGroupViewer({ data }: { data: any }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
|
|
@ -16,12 +15,11 @@ export function SharedGroupViewer({ data, token }: { data: SharedGroupData; toke
|
|||
const [progressMap, setProgressMap] = useState<Record<string, { currentIndex: number, total: number }>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const newProgress: Record<string, { currentIndex: number, total: number }> = {};
|
||||
items.forEach((item) => {
|
||||
const key = data.type === "DECK"
|
||||
? `flashcard_progress_${token}_${item.id}`
|
||||
: `quiz_progress_${token}_${item.id}`;
|
||||
items.forEach((item: any) => {
|
||||
const key = data.type === "DECK" ? `flashcard_progress_${item.id}` : `quiz_progress_${item.id}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
try {
|
||||
|
|
@ -31,19 +29,15 @@ export function SharedGroupViewer({ data, token }: { data: SharedGroupData; toke
|
|||
currentIndex: parsed.currentIndex || 0,
|
||||
total,
|
||||
};
|
||||
} catch {}
|
||||
} catch(e) {}
|
||||
}
|
||||
});
|
||||
setProgressMap(newProgress);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [items, data.type, token]);
|
||||
}, [items, data.type]);
|
||||
|
||||
const handleRestart = (e: React.MouseEvent, itemId: string) => {
|
||||
e.preventDefault();
|
||||
const key = data.type === "DECK"
|
||||
? `flashcard_progress_${token}_${itemId}`
|
||||
: `quiz_progress_${token}_${itemId}`;
|
||||
const key = data.type === "DECK" ? `flashcard_progress_${itemId}` : `quiz_progress_${itemId}`;
|
||||
localStorage.removeItem(key);
|
||||
setProgressMap(prev => {
|
||||
const next = { ...prev };
|
||||
|
|
@ -77,7 +71,7 @@ export function SharedGroupViewer({ data, token }: { data: SharedGroupData; toke
|
|||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{items.map((item) => (
|
||||
{items.map((item: any) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="group flex flex-col bg-bg-surface border border-border-light rounded-xl shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300"
|
||||
|
|
|
|||
|
|
@ -6,35 +6,29 @@ import { usePathname } from "next/navigation";
|
|||
import { FlashcardViewer } from "@/components/flashcards/FlashcardViewer";
|
||||
import { QuizViewer } from "@/components/quizzes/QuizViewer";
|
||||
import { CardList } from "@/components/flashcards/CardList";
|
||||
import type { SharedStudyItem } from "@/types/study";
|
||||
|
||||
interface SharedViewerProps {
|
||||
type: "flashcards" | "quizzes";
|
||||
data: SharedStudyItem;
|
||||
type: string;
|
||||
data: any;
|
||||
groupMode?: boolean;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export function SharedViewer({ type, data, groupMode = false, token }: SharedViewerProps) {
|
||||
export function SharedViewer({ type, data, groupMode = false }: SharedViewerProps) {
|
||||
const pathname = usePathname();
|
||||
const [restartKey, setRestartKey] = useState(0);
|
||||
const [view, setView] = useState<"study" | "list">("study");
|
||||
const [hasSavedSession, setHasSavedSession] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
const key = type === "flashcards"
|
||||
? `flashcard_progress_${token}_${data.id}`
|
||||
: `quiz_progress_${token}_${data.id}`;
|
||||
setHasSavedSession(Boolean(localStorage.getItem(key)));
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [type, data.id, restartKey, token]);
|
||||
// Check if there's a saved session for this item
|
||||
const key = type === "flashcards" ? `flashcard_progress_${data.id}` : `quiz_progress_${data.id}`;
|
||||
if (localStorage.getItem(key)) {
|
||||
setHasSavedSession(true);
|
||||
}
|
||||
}, [type, data.id, restartKey]);
|
||||
|
||||
function handleRestart() {
|
||||
const key = type === "flashcards"
|
||||
? `flashcard_progress_${token}_${data.id}`
|
||||
: `quiz_progress_${token}_${data.id}`;
|
||||
const key = type === "flashcards" ? `flashcard_progress_${data.id}` : `quiz_progress_${data.id}`;
|
||||
localStorage.removeItem(key);
|
||||
setHasSavedSession(false);
|
||||
setRestartKey(k => k + 1);
|
||||
|
|
@ -42,7 +36,7 @@ export function SharedViewer({ type, data, groupMode = false, token }: SharedVie
|
|||
|
||||
const [showTopics, setShowTopics] = useState(false);
|
||||
const categories = type === "quizzes" && data.questions
|
||||
? Array.from(new Set(data.questions.map((q) => q.category.toUpperCase()))).join(" · ")
|
||||
? Array.from(new Set(data.questions.map((q: any) => q.category.toUpperCase()))).join(" · ")
|
||||
: "";
|
||||
|
||||
return (
|
||||
|
|
@ -143,26 +137,22 @@ export function SharedViewer({ type, data, groupMode = false, token }: SharedVie
|
|||
view === "study" ? (
|
||||
<FlashcardViewer
|
||||
key={restartKey}
|
||||
cards={data.cards ?? []}
|
||||
cards={data.cards}
|
||||
deckId={data.id}
|
||||
isShared={true}
|
||||
storageNamespace={token}
|
||||
/>
|
||||
) : (
|
||||
<CardList cards={data.cards ?? []} />
|
||||
<CardList cards={data.cards} />
|
||||
)
|
||||
) : (
|
||||
<QuizViewer
|
||||
key={restartKey}
|
||||
quiz={{
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
questions: data.questions ?? [],
|
||||
...data,
|
||||
progress: [], // Start fresh since it's shared
|
||||
}}
|
||||
retakeIds={null}
|
||||
isShared={true}
|
||||
storageNamespace={token}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
import { getShareLink, getShareLinkMeta } from "@/services/shareService";
|
||||
import { getShareLink } from "@/services/shareService";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
||||
import Link from "next/link";
|
||||
import { SharedViewer } from "./SharedViewer";
|
||||
import { SharedGroupViewer } from "./SharedGroupViewer";
|
||||
import { buildShareMetadata } from "@/lib/shareMetadata";
|
||||
import type { Metadata } from "next";
|
||||
import type { SharedGroupData, SharedStudyItem } from "@/types/study";
|
||||
|
||||
type SharedContentType = "flashcards" | "quizzes";
|
||||
|
||||
interface SharedPageProps {
|
||||
params: Promise<{
|
||||
|
|
@ -18,20 +13,6 @@ interface SharedPageProps {
|
|||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata(
|
||||
props: SharedPageProps & { searchParams: Promise<{ itemId?: string }> },
|
||||
): Promise<Metadata> {
|
||||
const { classSlug, type, token } = await props.params;
|
||||
const { itemId } = await props.searchParams;
|
||||
|
||||
if (type !== "flashcards" && type !== "quizzes" && type !== "groups") {
|
||||
return buildShareMetadata(null);
|
||||
}
|
||||
|
||||
const link = await getShareLinkMeta(token);
|
||||
return buildShareMetadata(link, { classSlug, itemId, pathType: type });
|
||||
}
|
||||
|
||||
export default async function SharedPage(props: SharedPageProps & { searchParams: Promise<{ itemId?: string }> }) {
|
||||
const { classSlug, type, token } = await props.params;
|
||||
const { itemId } = await props.searchParams;
|
||||
|
|
@ -67,40 +48,27 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
|||
notFound();
|
||||
}
|
||||
|
||||
let targetData: SharedStudyItem | SharedGroupData | null = null;
|
||||
let targetType: SharedContentType = type === "flashcards" ? "flashcards" : "quizzes";
|
||||
let targetData: any = null;
|
||||
let targetType = type;
|
||||
|
||||
if (type === "groups" && link.group) {
|
||||
const groupClassSlug = link.group.class.slug;
|
||||
const validDecks = link.group.decks.filter(
|
||||
(deck) => link.group?.type === "DECK" && deck.class.slug === groupClassSlug
|
||||
);
|
||||
const validQuizSets = link.group.quizSets.filter(
|
||||
(quiz) => link.group?.type === "QUIZ" && quiz.class.slug === groupClassSlug
|
||||
);
|
||||
if (itemId) {
|
||||
// Find the specific item inside the group
|
||||
if (link.group.type === "DECK") {
|
||||
targetData = validDecks.find(d => d.id === itemId) ?? null;
|
||||
targetData = link.group.decks.find(d => d.id === itemId);
|
||||
targetType = "flashcards";
|
||||
} else {
|
||||
targetData = validQuizSets.find(q => q.id === itemId) ?? null;
|
||||
targetData = link.group.quizSets.find(q => q.id === itemId);
|
||||
targetType = "quizzes";
|
||||
}
|
||||
if (!targetData) notFound();
|
||||
} else {
|
||||
targetData = {
|
||||
...link.group,
|
||||
decks: validDecks,
|
||||
quizSets: validQuizSets,
|
||||
};
|
||||
targetData = link.group;
|
||||
}
|
||||
} else {
|
||||
targetData = type === "flashcards" ? link.deck : link.quizSet;
|
||||
}
|
||||
|
||||
if (!targetData) notFound();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-base flex flex-col">
|
||||
{/* Read-only Header */}
|
||||
|
|
@ -137,13 +105,12 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
|||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-5xl px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
|
||||
{type === "groups" && !itemId ? (
|
||||
<SharedGroupViewer data={targetData as SharedGroupData} token={token} />
|
||||
<SharedGroupViewer data={targetData} />
|
||||
) : (
|
||||
<SharedViewer
|
||||
type={targetType}
|
||||
data={targetData as SharedStudyItem}
|
||||
data={targetData}
|
||||
groupMode={type === "groups"}
|
||||
token={token}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,188 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface DailyActivity {
|
||||
date: string;
|
||||
flashcards: number;
|
||||
questions: number;
|
||||
total: number;
|
||||
level: 0 | 1 | 2 | 3 | 4;
|
||||
}
|
||||
|
||||
interface ActivitySummary {
|
||||
days: DailyActivity[];
|
||||
today: string;
|
||||
currentStreak: number;
|
||||
}
|
||||
|
||||
const levelClasses = [
|
||||
"bg-text-heading/8 dark:bg-white/10",
|
||||
"bg-primary/30 dark:bg-primary/45",
|
||||
"bg-primary/70 dark:bg-primary",
|
||||
"bg-accent/75 dark:bg-accent",
|
||||
"bg-[#e9a91b] dark:bg-[#fbbf24]",
|
||||
] as const;
|
||||
|
||||
export function ActivityBanner({ onNewClass }: { onNewClass: () => void }) {
|
||||
const [summary, setSummary] = useState<ActivitySummary | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/activity")
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error("Failed to load activity");
|
||||
return response.json();
|
||||
})
|
||||
.then(setSummary)
|
||||
.catch(() => setError(true));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="relative mb-10 overflow-hidden rounded-[2rem] border border-border-light bg-bg-surface px-5 py-7 text-text-heading shadow-[var(--shadow-card)] sm:px-8 sm:py-9 dark:border-white/5 dark:bg-[#111827] dark:text-white">
|
||||
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-primary via-primary-light to-accent dark:hidden" />
|
||||
<div className="absolute -right-16 -top-24 h-64 w-64 rounded-full bg-primary/12 blur-3xl dark:bg-primary/70" />
|
||||
<div className="absolute -bottom-28 right-1/3 h-52 w-52 rounded-full bg-accent/10 blur-3xl dark:bg-accent/35" />
|
||||
<div className="relative mb-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.22em] text-primary dark:text-white/55">Personal library</p>
|
||||
<h1 className="editorial-title mt-1 text-3xl text-text-heading sm:text-4xl dark:text-white">Study activity</h1>
|
||||
</div>
|
||||
<button onClick={onNewClass} className="inline-flex min-h-11 shrink-0 items-center justify-center gap-2 rounded-xl bg-primary px-4 text-sm font-bold text-white shadow-md transition-[background-color,transform,box-shadow] hover:-translate-y-0.5 hover:bg-primary-hover hover:shadow-lg sm:min-h-12 sm:px-5 dark:bg-white dark:text-[#172033] dark:hover:bg-white/90">
|
||||
<span className="text-xl leading-none">+</span> New class
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="relative rounded-xl border border-error/20 bg-error-bg px-4 py-5 text-sm text-error dark:border-white/15 dark:bg-white/8 dark:text-white/70" role="alert">
|
||||
Study activity could not be loaded. Your library is still available below.
|
||||
</div>
|
||||
)}
|
||||
{!summary && !error && <div className="relative h-52 animate-subtle-pulse rounded-2xl bg-bg-surface-alt/70 dark:bg-white/8" />}
|
||||
{summary && (
|
||||
<div className="relative grid gap-6 xl:grid-cols-[minmax(0,1fr)_10rem] xl:items-center">
|
||||
<div className="min-w-0">
|
||||
<ActivityHeatmap days={summary.days} today={summary.today} />
|
||||
</div>
|
||||
<StreakDisplay streak={summary.currentStreak} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityHeatmap({ days, today }: { days: DailyActivity[]; today: string }) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [activeDay, setActiveDay] = useState<DailyActivity | null>(null);
|
||||
const weeks = useMemo(
|
||||
() => Array.from({ length: 53 }, (_, index) => days.slice(index * 7, index * 7 + 7)),
|
||||
[days]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
if (container) container.scrollLeft = container.scrollWidth;
|
||||
}, [days]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative mb-2 h-12">
|
||||
<p className="absolute bottom-0 left-0 text-xs font-semibold text-text-secondary dark:text-white/55">Last 53 weeks · Arizona time</p>
|
||||
<div className="absolute right-0 top-0">
|
||||
{activeDay && <DayTooltip day={activeDay} />}
|
||||
</div>
|
||||
</div>
|
||||
<div ref={scrollRef} className="overflow-x-auto pb-2 [scrollbar-color:var(--color-border)_transparent] dark:[scrollbar-color:rgba(255,255,255,.25)_transparent]">
|
||||
<div className="grid w-max grid-cols-[2rem_auto] gap-2">
|
||||
<div className="pt-6 text-[10px] font-semibold leading-[15px] text-text-muted dark:text-white/45" aria-hidden>
|
||||
<div className="h-[15px]" />
|
||||
<div>Mon</div>
|
||||
<div className="h-[15px]" />
|
||||
<div>Wed</div>
|
||||
<div className="h-[15px]" />
|
||||
<div>Fri</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex h-4 gap-[3px] text-[10px] font-semibold text-text-muted dark:text-white/45" aria-hidden>
|
||||
{weeks.map((week, index) => (
|
||||
<div key={week[0]?.date ?? index} className="w-3">
|
||||
{getMonthLabel(week, weeks[index - 1])}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-[3px]" role="group" aria-label="Study activity over the last 53 weeks">
|
||||
{weeks.map((week, weekIndex) => (
|
||||
<div key={week[0]?.date ?? weekIndex} className="grid grid-rows-7 gap-[3px]">
|
||||
{week.map((day) => day.date > today ? (
|
||||
<span key={day.date} aria-hidden className="h-3 w-3" />
|
||||
) : (
|
||||
<button
|
||||
key={day.date}
|
||||
type="button"
|
||||
aria-label={getDayAriaLabel(day)}
|
||||
onMouseEnter={() => setActiveDay(day)}
|
||||
onMouseLeave={() => setActiveDay(null)}
|
||||
onFocus={() => setActiveDay(day)}
|
||||
onBlur={() => setActiveDay(null)}
|
||||
onClick={() => setActiveDay((current) => current?.date === day.date ? null : day)}
|
||||
className={`h-3 w-3 rounded-[3px] border border-text-heading/6 ${levelClasses[day.level]} focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-white/8 dark:focus-visible:outline-white`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-end gap-1.5 text-[10px] font-semibold text-text-muted dark:text-white/45" aria-label="Activity intensity from less to more">
|
||||
<span>Less</span>
|
||||
{levelClasses.map((className, index) => <span key={index} className={`h-3 w-3 rounded-[3px] border border-text-heading/6 dark:border-white/8 ${className}`} />)}
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DayTooltip({ day }: { day: DailyActivity }) {
|
||||
const formatted = new Date(`${day.date}T12:00:00Z`).toLocaleDateString("en-US", {
|
||||
timeZone: "America/Phoenix",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
return (
|
||||
<div role="tooltip" className="rounded-lg border border-border bg-bg-surface px-3 py-2 text-right text-xs shadow-xl dark:border-white/15 dark:bg-[#0d1422]">
|
||||
<div className="font-bold text-text-heading dark:text-white">{formatted} · {day.total} total</div>
|
||||
<div className="mt-0.5 text-text-secondary dark:text-white/60">{day.flashcards} flashcards · {day.questions} questions</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StreakDisplay({ streak }: { streak: number }) {
|
||||
const size = 24 + Math.min(streak, 30) * 0.6;
|
||||
const color = streak === 0 ? "text-text-muted dark:text-white/30" : streak < 7 ? "text-accent" : streak < 30 ? "text-[#e6602f] dark:text-[#ff7a36]" : "text-[#d4930d] dark:text-[#fbbf24]";
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-4 rounded-2xl border border-primary/15 bg-bg-callout/70 px-5 py-4 shadow-sm xl:flex-col xl:gap-2 xl:text-center dark:border-white/12 dark:bg-white/7 dark:shadow-none">
|
||||
<svg viewBox="0 0 24 24" aria-hidden className={`shrink-0 fill-current ${color}`} style={{ width: size, height: size }}>
|
||||
<path d="M13.5 2.1c.4 3.1-1.4 4.5-2.6 6.1-1 1.3-1.5 2.4-.8 4.1.5-1.4 1.5-2.3 2.5-3.2 1.8 1.6 3.4 3.6 3.4 6.3 0 2.5-1.7 4.6-4 4.6s-4-2-4-4.6c0-1.2.4-2.3 1-3.3-2.4 1.3-4 3.7-4 6.3 0 3.1 2.8 5.6 7 5.6s7-2.7 7-6.7c0-5.6-3.4-10.3-5.5-15.2Z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="text-3xl font-extrabold">{streak}</div>
|
||||
<div className="text-xs font-semibold text-text-secondary dark:text-white/55">day streak</div>
|
||||
<div className="mt-1 text-[10px] text-text-muted dark:text-white/40">20 activities per day</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getMonthLabel(week: DailyActivity[], previousWeek?: DailyActivity[]) {
|
||||
const firstOfMonth = week.find((day) => Number(day.date.slice(8, 10)) <= 7);
|
||||
if (!firstOfMonth) return "";
|
||||
const month = firstOfMonth.date.slice(5, 7);
|
||||
if (previousWeek?.some((day) => day.date.slice(5, 7) === month)) return "";
|
||||
return new Date(`${firstOfMonth.date}T12:00:00Z`).toLocaleDateString("en-US", { month: "short", timeZone: "America/Phoenix" });
|
||||
}
|
||||
|
||||
function getDayAriaLabel(day: DailyActivity) {
|
||||
return `${day.date}: ${day.flashcards} flashcards, ${day.questions} questions, ${day.total} total activities`;
|
||||
}
|
||||
|
|
@ -41,7 +41,6 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
|
|||
body: JSON.stringify({ front: editFront, back: editBack }),
|
||||
});
|
||||
setEditingId(null);
|
||||
window.dispatchEvent(new Event("study-decks-changed"));
|
||||
onCardsChanged();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
|
|
@ -51,7 +50,6 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
|
|||
async function deleteCard(id: string) {
|
||||
if (!confirm("Delete this card?")) return;
|
||||
await fetch(`/api/cards/${id}`, { method: "DELETE" });
|
||||
window.dispatchEvent(new Event("study-decks-changed"));
|
||||
onCardsChanged();
|
||||
}
|
||||
|
||||
|
|
@ -67,7 +65,6 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
|
|||
setNewFront("");
|
||||
setNewBack("");
|
||||
setShowAdd(false);
|
||||
window.dispatchEvent(new Event("study-decks-changed"));
|
||||
onCardsChanged();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
|
|
@ -92,7 +89,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
|
|||
</div>
|
||||
|
||||
{/* Card list */}
|
||||
{cards.map((card) => (
|
||||
{cards.map((card, index) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="overflow-hidden rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)]"
|
||||
|
|
|
|||
|
|
@ -1,19 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { recordStudyActivity } from "@/lib/activityClient";
|
||||
import { normalizeProgress } from "@/lib/progressNormalization";
|
||||
import {
|
||||
STREAK_FLAME_THRESHOLD,
|
||||
StreakBurst,
|
||||
StreakCounter,
|
||||
isStreakMilestone,
|
||||
streakBurstDuration,
|
||||
streakTier,
|
||||
type StreakTier,
|
||||
} from "./StreakFlame";
|
||||
|
||||
interface Card {
|
||||
id: string;
|
||||
|
|
@ -26,78 +15,46 @@ interface ProgressData {
|
|||
currentIndex: number;
|
||||
orderJson: string;
|
||||
cardResultsJson: string | null;
|
||||
sessionId: string;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
interface FlashcardViewerProps {
|
||||
cards: Card[];
|
||||
deckId: string;
|
||||
isShared?: boolean;
|
||||
storageNamespace?: string;
|
||||
initialProgress?: ProgressData | null;
|
||||
}
|
||||
|
||||
type CardResult = "correct" | "missed";
|
||||
|
||||
function initialFlashcardState(cards: Card[], progress?: ProgressData | null) {
|
||||
if (!progress) {
|
||||
return {
|
||||
order: cards.map((card) => card.id),
|
||||
currentIndex: 0,
|
||||
data: {} as Record<string, CardResult>,
|
||||
completed: cards.length === 0,
|
||||
wasRecovered: false,
|
||||
};
|
||||
}
|
||||
return normalizeProgress({
|
||||
orderJson: progress.orderJson,
|
||||
currentIndex: progress.currentIndex,
|
||||
dataJson: progress.cardResultsJson,
|
||||
liveIds: cards.map((card) => card.id),
|
||||
isValidValue: (value): value is CardResult =>
|
||||
value === "correct" || value === "missed",
|
||||
});
|
||||
}
|
||||
|
||||
export function FlashcardViewer({
|
||||
cards,
|
||||
deckId,
|
||||
isShared = false,
|
||||
storageNamespace,
|
||||
initialProgress,
|
||||
}: FlashcardViewerProps) {
|
||||
const sharedStorageKey = `flashcard_progress_${storageNamespace ?? "shared"}_${deckId}`;
|
||||
const [initialState] = useState(() => initialFlashcardState(cards, initialProgress));
|
||||
export function FlashcardViewer({ cards, deckId, isShared = false, initialProgress }: FlashcardViewerProps) {
|
||||
const [isLoaded, setIsLoaded] = useState(!isShared);
|
||||
|
||||
const [order, setOrder] = useState<string[]>(initialState.order);
|
||||
const [currentIndex, setCurrentIndex] = useState(initialState.currentIndex);
|
||||
const [order, setOrder] = useState<string[]>(
|
||||
initialProgress ? JSON.parse(initialProgress.orderJson) : cards.map((c) => c.id)
|
||||
);
|
||||
const [currentIndex, setCurrentIndex] = useState(
|
||||
initialProgress ? initialProgress.currentIndex : 0
|
||||
);
|
||||
const [isFlipped, setIsFlipped] = useState(false);
|
||||
const [hasFlippedOnce, setHasFlippedOnce] = useState(false);
|
||||
const [results, setResults] = useState<Record<string, CardResult>>(initialState.data);
|
||||
const [results, setResults] = useState<Record<string, CardResult>>(
|
||||
initialProgress && initialProgress.cardResultsJson
|
||||
? JSON.parse(initialProgress.cardResultsJson)
|
||||
: {}
|
||||
);
|
||||
const [isShuffled, setIsShuffled] = useState<boolean>(
|
||||
initialProgress ? initialProgress.mode === "SHUFFLED" : false
|
||||
);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
const [restoreWarning, setRestoreWarning] = useState(
|
||||
initialState.wasRecovered
|
||||
? "Some saved progress was invalid or referenced deleted cards, so it was safely repaired."
|
||||
: null
|
||||
);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
|
||||
// If progress is provided and index is already at or past the end, it means completed
|
||||
const [completed, setCompleted] = useState(initialState.completed);
|
||||
const [completed, setCompleted] = useState(
|
||||
initialProgress
|
||||
? initialProgress.currentIndex >= JSON.parse(initialProgress.orderJson).length
|
||||
: false
|
||||
);
|
||||
const [swipeClass, setSwipeClass] = useState("");
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const gradingRef = useRef(false);
|
||||
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const sessionIdRef = useRef(
|
||||
initialProgress?.sessionId ?? globalThis.crypto.randomUUID()
|
||||
);
|
||||
const revisionRef = useRef(initialProgress?.revision ?? 0);
|
||||
const saveQueueRef = useRef(Promise.resolve());
|
||||
|
||||
// Touch/drag state
|
||||
const dragRef = useRef({ startX: 0, currentX: 0, isDragging: false });
|
||||
|
|
@ -105,48 +62,7 @@ export function FlashcardViewer({
|
|||
const currentCard = order.length > 0 ? cards.find((c) => c.id === order[currentIndex]) : null;
|
||||
const correctCount = Object.values(results).filter((r) => r === "correct").length;
|
||||
const missedCount = Object.values(results).filter((r) => r === "missed").length;
|
||||
|
||||
// Current run of consecutive correct cards, derived by walking back from the
|
||||
// current position. Deriving it (rather than storing it) keeps resumed
|
||||
// sessions and the "previous card" button correct without extra persistence.
|
||||
const streak = useMemo(() => {
|
||||
let run = 0;
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
if (results[order[i]] !== "correct") break;
|
||||
run++;
|
||||
}
|
||||
return run;
|
||||
}, [order, currentIndex, results]);
|
||||
|
||||
const isOnFire = streak >= STREAK_FLAME_THRESHOLD;
|
||||
|
||||
// Milestone celebration. The first settled streak is only a baseline, so
|
||||
// resuming a session mid-streak never fires a burst on arrival.
|
||||
const [burst, setBurst] = useState<{ streak: number; tier: StreakTier; id: number } | null>(null);
|
||||
const prevStreakRef = useRef(0);
|
||||
const streakBaselineRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoaded) return;
|
||||
|
||||
const previous = prevStreakRef.current;
|
||||
prevStreakRef.current = streak;
|
||||
|
||||
if (!streakBaselineRef.current) {
|
||||
streakBaselineRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (streak > previous && isStreakMilestone(streak)) {
|
||||
setBurst({ streak, tier: streakTier(streak), id: Date.now() });
|
||||
}
|
||||
}, [streak, isLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!burst) return;
|
||||
const timer = setTimeout(() => setBurst(null), streakBurstDuration(burst.tier));
|
||||
return () => clearTimeout(timer);
|
||||
}, [burst]);
|
||||
const totalGraded = correctCount + missedCount;
|
||||
|
||||
// Auto-hide toast
|
||||
useEffect(() => {
|
||||
|
|
@ -158,104 +74,29 @@ export function FlashcardViewer({
|
|||
|
||||
// Load from localStorage if shared
|
||||
useEffect(() => {
|
||||
if (!isShared) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
if (isShared) {
|
||||
try {
|
||||
const saved = localStorage.getItem(sharedStorageKey);
|
||||
const saved = localStorage.getItem(`flashcard_progress_${deckId}`);
|
||||
if (saved) {
|
||||
const parsed: unknown = JSON.parse(saved);
|
||||
const record =
|
||||
typeof parsed === "object" && parsed !== null
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
const restored = normalizeProgress({
|
||||
orderJson: record.order,
|
||||
currentIndex: record.currentIndex,
|
||||
dataJson: record.results,
|
||||
liveIds: cards.map((card) => card.id),
|
||||
isValidValue: (value): value is CardResult =>
|
||||
value === "correct" || value === "missed",
|
||||
});
|
||||
setOrder(restored.order);
|
||||
setCurrentIndex(restored.currentIndex);
|
||||
setResults(restored.data);
|
||||
setIsShuffled(record.mode === "SHUFFLED");
|
||||
setCompleted(restored.completed);
|
||||
if (restored.wasRecovered) {
|
||||
setRestoreWarning(
|
||||
"Some saved progress was invalid or referenced deleted cards, so it was safely repaired."
|
||||
);
|
||||
const parsed = JSON.parse(saved);
|
||||
setOrder(parsed.order || cards.map(c => c.id));
|
||||
setCurrentIndex(parsed.currentIndex || 0);
|
||||
setResults(parsed.results || {});
|
||||
setIsShuffled(parsed.mode === "SHUFFLED");
|
||||
|
||||
if (parsed.currentIndex >= (parsed.order?.length || cards.length)) {
|
||||
setCompleted(true);
|
||||
}
|
||||
setToastMessage("Session restored");
|
||||
}
|
||||
} catch {
|
||||
setRestoreWarning(
|
||||
"Saved progress could not be read. A fresh session was started safely."
|
||||
);
|
||||
} catch (e) {
|
||||
// Fallback to defaults
|
||||
}
|
||||
setIsLoaded(true);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isShared, sharedStorageKey, cards]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const saveProgress = useCallback(
|
||||
(
|
||||
orderArr: string[],
|
||||
index: number,
|
||||
cardResults: Record<string, CardResult>,
|
||||
mode: "SEQUENTIAL" | "SHUFFLED"
|
||||
) => {
|
||||
if (isShared) {
|
||||
localStorage.setItem(
|
||||
sharedStorageKey,
|
||||
JSON.stringify({
|
||||
mode,
|
||||
currentIndex: index,
|
||||
order: orderArr,
|
||||
results: cardResults,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const revision = ++revisionRef.current;
|
||||
saveQueueRef.current = saveQueueRef.current
|
||||
.then(async () => {
|
||||
const response = await fetch("/api/progress", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contentType: "DECK",
|
||||
contentId: deckId,
|
||||
mode,
|
||||
currentIndex: index,
|
||||
order: orderArr,
|
||||
cardResults,
|
||||
sessionId: sessionIdRef.current,
|
||||
revision,
|
||||
}),
|
||||
});
|
||||
if (response.status === 409) {
|
||||
setToastMessage("Progress changed in another session; reload to continue safely.");
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error("Progress save failed");
|
||||
})
|
||||
.catch(() => {
|
||||
setToastMessage("Progress could not be saved. Your current view is unchanged.");
|
||||
});
|
||||
},
|
||||
[deckId, isShared, sharedStorageKey]
|
||||
);
|
||||
}, [isShared, deckId, cards]);
|
||||
|
||||
function toggleShuffle() {
|
||||
if (isTransitioning) return;
|
||||
const newShuffled = !isShuffled;
|
||||
setIsShuffled(newShuffled);
|
||||
|
||||
|
|
@ -279,13 +120,7 @@ export function FlashcardViewer({
|
|||
// Grade the current card
|
||||
const gradeCard = useCallback(
|
||||
(grade: CardResult) => {
|
||||
if (!currentCard || !hasFlippedOnce || gradingRef.current) return;
|
||||
gradingRef.current = true;
|
||||
setIsTransitioning(true);
|
||||
|
||||
if (!isShared) {
|
||||
recordStudyActivity("FLASHCARD").catch(() => {});
|
||||
}
|
||||
if (!currentCard || !hasFlippedOnce) return;
|
||||
|
||||
setToastMessage(null);
|
||||
const newResults = { ...results, [currentCard.id]: grade };
|
||||
|
|
@ -294,17 +129,14 @@ export function FlashcardViewer({
|
|||
// Animate
|
||||
setSwipeClass(grade === "correct" ? "animate-swipe-right" : "animate-swipe-left");
|
||||
|
||||
transitionTimerRef.current = setTimeout(() => {
|
||||
gradingRef.current = false;
|
||||
setIsTransitioning(false);
|
||||
setTimeout(() => {
|
||||
setSwipeClass("");
|
||||
setIsFlipped(false);
|
||||
setHasFlippedOnce(false);
|
||||
|
||||
if (currentIndex + 1 >= order.length) {
|
||||
setCompleted(true);
|
||||
setCurrentIndex(order.length);
|
||||
saveProgress(order, order.length, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
|
||||
saveProgress(order, currentIndex, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
|
||||
} else {
|
||||
const nextIndex = currentIndex + 1;
|
||||
setCurrentIndex(nextIndex);
|
||||
|
|
@ -312,13 +144,13 @@ export function FlashcardViewer({
|
|||
}
|
||||
}, 350);
|
||||
},
|
||||
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled, isShared, saveProgress]
|
||||
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled]
|
||||
);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (completed || isTransitioning) return;
|
||||
if (completed) return;
|
||||
|
||||
if (e.key === " " || e.key === "Enter" || e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
|
|
@ -336,7 +168,7 @@ export function FlashcardViewer({
|
|||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [completed, isFlipped, gradeCard, hasFlippedOnce, isTransitioning]);
|
||||
}, [completed, isFlipped, gradeCard]);
|
||||
|
||||
// Touch handlers for swipe
|
||||
function handleTouchStart(e: React.TouchEvent) {
|
||||
|
|
@ -371,10 +203,39 @@ export function FlashcardViewer({
|
|||
}
|
||||
}
|
||||
|
||||
// Save progress (debounced / fire-and-forget)
|
||||
function saveProgress(
|
||||
orderArr: string[],
|
||||
index: number,
|
||||
cardResults: Record<string, CardResult>,
|
||||
m: "SEQUENTIAL" | "SHUFFLED"
|
||||
) {
|
||||
if (isShared) {
|
||||
localStorage.setItem(`flashcard_progress_${deckId}`, JSON.stringify({
|
||||
mode: m,
|
||||
currentIndex: index,
|
||||
order: orderArr,
|
||||
results: cardResults,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
fetch("/api/progress", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contentType: "DECK",
|
||||
contentId: deckId,
|
||||
mode: m,
|
||||
currentIndex: index,
|
||||
orderJson: JSON.stringify(orderArr),
|
||||
cardResultsJson: JSON.stringify(cardResults),
|
||||
}),
|
||||
}).catch(() => {}); // fire-and-forget
|
||||
}
|
||||
|
||||
// Restart handlers
|
||||
function restartFullSet() {
|
||||
if (isTransitioning) return;
|
||||
gradingRef.current = false;
|
||||
let newOrder: string[];
|
||||
if (isShuffled) {
|
||||
newOrder = [...cards.map((c) => c.id)].sort(() => Math.random() - 0.5);
|
||||
|
|
@ -391,7 +252,6 @@ export function FlashcardViewer({
|
|||
}
|
||||
|
||||
function redoMissed() {
|
||||
if (isTransitioning) return;
|
||||
const missedIds = Object.entries(results)
|
||||
.filter(([, r]) => r === "missed")
|
||||
.map(([id]) => id);
|
||||
|
|
@ -417,11 +277,6 @@ export function FlashcardViewer({
|
|||
if (completed) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
{restoreWarning && (
|
||||
<div className="mb-4 w-full max-w-md rounded-xl bg-badge-bg px-4 py-3 text-sm text-text-secondary" role="status">
|
||||
{restoreWarning}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full max-w-md rounded-3xl border border-border-light bg-bg-surface p-8 text-center shadow-[var(--shadow-card)]">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-success-bg mb-4">
|
||||
<svg className="w-7 h-7 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -478,27 +333,21 @@ export function FlashcardViewer({
|
|||
// Study view
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
{restoreWarning && (
|
||||
<div className="mb-4 w-full max-w-4xl rounded-xl bg-badge-bg px-4 py-3 text-sm text-text-secondary" role="status">
|
||||
{restoreWarning}
|
||||
</div>
|
||||
)}
|
||||
{/* Running tally */}
|
||||
<div className="mb-5 w-full max-w-4xl rounded-xl border border-border-light bg-bg-surface/70 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-success font-medium">{correctCount} ✓</span>
|
||||
<span className="text-error font-medium">{missedCount} ✗</span>
|
||||
{streak >= 2 && <StreakCounter streak={streak} />}
|
||||
</div>
|
||||
<span className="text-text-muted">
|
||||
{currentIndex + 1} of {order.length}
|
||||
</span>
|
||||
</div>
|
||||
{/* Progress bar — turns to flame once the streak is hot */}
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-bg-surface rounded-full h-2 mt-2 border border-border-light/50 overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${isOnFire ? "streak-bar-flame" : "bg-primary"}`}
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${((currentIndex + 1) / order.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -511,7 +360,6 @@ export function FlashcardViewer({
|
|||
ref={cardRef}
|
||||
className={`perspective-1000 cursor-pointer select-none relative ${swipeClass}`}
|
||||
onClick={() => {
|
||||
if (isTransitioning) return;
|
||||
setToastMessage(null);
|
||||
setIsFlipped(!isFlipped);
|
||||
if (!isFlipped) setHasFlippedOnce(true);
|
||||
|
|
@ -576,9 +424,6 @@ export function FlashcardViewer({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Streak milestone burst */}
|
||||
{burst && <StreakBurst key={burst.id} streak={burst.streak} tier={burst.tier} />}
|
||||
|
||||
{/* Toast Notification */}
|
||||
{toastMessage && (
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-30 animate-fade-in pointer-events-none">
|
||||
|
|
@ -597,8 +442,7 @@ export function FlashcardViewer({
|
|||
<div className="flex items-center gap-2 md:gap-4 animate-fade-in pointer-events-auto">
|
||||
<button
|
||||
onClick={() => gradeCard("missed")}
|
||||
disabled={isTransitioning}
|
||||
className="flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border-2 border-error/30 bg-bg-surface px-4 font-bold text-error transition-colors hover:bg-error-bg disabled:cursor-not-allowed disabled:opacity-50 sm:flex-none sm:px-7"
|
||||
className="flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border-2 border-error/30 bg-bg-surface px-4 font-bold text-error transition-colors hover:bg-error-bg sm:flex-none sm:px-7"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
|
|
@ -607,8 +451,7 @@ export function FlashcardViewer({
|
|||
</button>
|
||||
<button
|
||||
onClick={() => gradeCard("correct")}
|
||||
disabled={isTransitioning}
|
||||
className="flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border-2 border-success/30 bg-bg-surface px-4 font-bold text-success transition-colors hover:bg-success-bg disabled:cursor-not-allowed disabled:opacity-50 sm:flex-none sm:px-7"
|
||||
className="flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border-2 border-success/30 bg-bg-surface px-4 font-bold text-success transition-colors hover:bg-success-bg sm:flex-none sm:px-7"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
|
|
@ -624,7 +467,6 @@ export function FlashcardViewer({
|
|||
{currentIndex > 0 ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isTransitioning) return;
|
||||
const prevIndex = currentIndex - 1;
|
||||
setCurrentIndex(prevIndex);
|
||||
setIsFlipped(false);
|
||||
|
|
@ -636,7 +478,6 @@ export function FlashcardViewer({
|
|||
setResults(newResults);
|
||||
saveProgress(order, prevIndex, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
|
||||
}}
|
||||
disabled={isTransitioning}
|
||||
className="p-2 rounded-full text-text-muted hover:bg-bg-surface-alt hover:text-text-heading transition-all duration-200 cursor-pointer bg-bg-base/50"
|
||||
title="Previous Card"
|
||||
>
|
||||
|
|
@ -648,7 +489,6 @@ export function FlashcardViewer({
|
|||
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
disabled={isTransitioning}
|
||||
className={`p-2 rounded-full transition-all duration-200 cursor-pointer bg-bg-base/50 ${
|
||||
isShuffled
|
||||
? "text-primary hover:bg-primary/10"
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useId, type CSSProperties } from "react";
|
||||
|
||||
/**
|
||||
* Streak visuals for the flashcard viewer. A streak is a run of consecutive
|
||||
* "correct" grades; milestones fire a flame burst that clears itself.
|
||||
*/
|
||||
|
||||
export type StreakTier = 1 | 2 | 3;
|
||||
|
||||
/** Milestones: 5, then every 10 (10, 20, 30, ...). */
|
||||
export function isStreakMilestone(streak: number) {
|
||||
return streak === 5 || (streak >= 10 && streak % 10 === 0);
|
||||
}
|
||||
|
||||
export function streakTier(streak: number): StreakTier {
|
||||
if (streak >= 20) return 3;
|
||||
if (streak >= 10) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** A streak this long turns the progress bar (and counter) to flame. */
|
||||
export const STREAK_FLAME_THRESHOLD = 5;
|
||||
|
||||
const TIER_COPY: Record<StreakTier, string> = {
|
||||
1: "On fire",
|
||||
2: "Blazing",
|
||||
3: "Unstoppable",
|
||||
};
|
||||
|
||||
const TIER_FLAME_SIZE: Record<StreakTier, string> = {
|
||||
1: "h-16 w-16",
|
||||
2: "h-24 w-24",
|
||||
3: "h-36 w-36 md:h-44 md:w-44",
|
||||
};
|
||||
|
||||
const TIER_HEADING_SIZE: Record<StreakTier, string> = {
|
||||
1: "text-lg",
|
||||
2: "text-2xl",
|
||||
3: "text-4xl md:text-5xl",
|
||||
};
|
||||
|
||||
const TIER_DURATION: Record<StreakTier, number> = {
|
||||
1: 1600,
|
||||
2: 1900,
|
||||
3: 2400,
|
||||
};
|
||||
|
||||
export function streakBurstDuration(tier: StreakTier) {
|
||||
return TIER_DURATION[tier];
|
||||
}
|
||||
|
||||
export function FlameIcon({ className }: { className?: string }) {
|
||||
// useId() output contains punctuation that is unsafe inside a url(#...)
|
||||
// reference, so strip it down to an id the gradient fill can resolve.
|
||||
const id = `flame-gradient-${useId().replace(/[^a-zA-Z0-9]/g, "")}`;
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<linearGradient id={id} x1="0" y1="1" x2="0" y2="0">
|
||||
<stop offset="0%" stopColor="#ea580c" />
|
||||
<stop offset="55%" stopColor="#fb923c" />
|
||||
<stop offset="100%" stopColor="#fbbf24" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill={`url(#${id})`}
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12.963 2.286a.75.75 0 0 0-1.071-.136 9.742 9.742 0 0 0-3.539 6.177A7.547 7.547 0 0 1 6.648 6.61a.75.75 0 0 0-1.152-.082A9 9 0 1 0 15.68 4.534a7.46 7.46 0 0 1-2.717-2.248ZM15.75 14.25a3.75 3.75 0 1 1-7.313-1.172c.628.465 1.35.81 2.133 1a5.99 5.99 0 0 1 1.925-3.547 3.75 3.75 0 0 1 3.255 3.719Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The celebratory flame overlay. Rendered inside the card's relative wrapper and
|
||||
* never interactive, so taps still reach the card underneath.
|
||||
*/
|
||||
export function StreakBurst({ streak, tier }: { streak: number; tier: StreakTier }) {
|
||||
return (
|
||||
<div
|
||||
className="streak-scrim pointer-events-none absolute inset-0 z-40 flex items-center justify-center rounded-[2rem]"
|
||||
style={{ "--streak-pop-duration": `${TIER_DURATION[tier]}ms` } as CSSProperties}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="streak-pop flex flex-col items-center">
|
||||
<FlameIcon className={`streak-pop-flame drop-shadow-[0_6px_24px_rgba(234,88,12,0.55)] ${TIER_FLAME_SIZE[tier]}`} />
|
||||
<span className={`streak-pop-heading mt-1 font-black uppercase tracking-wide ${TIER_HEADING_SIZE[tier]}`}>
|
||||
{TIER_COPY[tier]}
|
||||
</span>
|
||||
<span className="mt-1 text-sm font-semibold text-text-heading">{streak} in a row</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The persistent "x in a row" counter shown next to the running tally. */
|
||||
export function StreakCounter({ streak }: { streak: number }) {
|
||||
const onFire = streak >= STREAK_FLAME_THRESHOLD;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`streak-chip inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-bold whitespace-nowrap ${
|
||||
onFire ? "streak-chip-flame" : "bg-bg-surface-alt text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
{onFire && <FlameIcon className="h-3.5 w-3.5 shrink-0" />}
|
||||
{streak} in a row
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -43,11 +43,9 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) {
|
|||
return;
|
||||
}
|
||||
|
||||
const incompleteRows = cards.flatMap((card, index) =>
|
||||
card.front.trim() && card.back.trim() ? [] : [index + 1]
|
||||
);
|
||||
if (incompleteRows.length > 0) {
|
||||
setError(`Complete or remove card row${incompleteRows.length === 1 ? "" : "s"}: ${incompleteRows.join(", ")}.`);
|
||||
const validCards = cards.filter((c) => c.front.trim() && c.back.trim());
|
||||
if (validCards.length === 0) {
|
||||
setError("Please add at least one complete card.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -63,7 +61,7 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) {
|
|||
type: "flashcards",
|
||||
deckName: deckName,
|
||||
description: description || undefined,
|
||||
cards: cards.map((c) => ({
|
||||
cards: validCards.map((c) => ({
|
||||
front: c.front,
|
||||
back: c.back,
|
||||
})),
|
||||
|
|
@ -72,14 +70,12 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) {
|
|||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const response = await res.json().catch(() => null) as { error?: string } | null;
|
||||
throw new Error(response?.error ?? "Failed to create deck");
|
||||
throw new Error("Failed to create deck");
|
||||
}
|
||||
|
||||
window.dispatchEvent(new Event("study-decks-changed"));
|
||||
onCreated();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create deck");
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" }) {
|
||||
const [instructions, setInstructions] = useState("");
|
||||
|
|
@ -8,33 +8,13 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
|
|||
const [saving, setSaving] = useState(false);
|
||||
const [saveResult, setSaveResult] = useState<"success" | "error" | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const loadInstructions = useCallback(async (signal?: AbortSignal) => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/settings/llm-instructions?type=${importType}`, { signal });
|
||||
if (!response.ok) throw new Error("Instructions could not be loaded.");
|
||||
const data = await response.json() as { value?: unknown };
|
||||
if (typeof data.value !== "string") throw new Error("The server returned invalid instructions.");
|
||||
setInstructions(data.value);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) return;
|
||||
setLoadError(error instanceof Error ? error.message : "Instructions could not be loaded.");
|
||||
} finally {
|
||||
if (!signal?.aborted) setLoading(false);
|
||||
}
|
||||
}, [importType]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => void loadInstructions(controller.signal), 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [loadInstructions]);
|
||||
fetch(`/api/settings/llm-instructions?type=${importType}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => setInstructions(data.value))
|
||||
.finally(() => setLoading(false));
|
||||
}, [importType]);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
|
|
@ -64,51 +44,32 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
|
|||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ value: "__RESET__" }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to reset instructions");
|
||||
const data = await res.json();
|
||||
if (typeof data.value !== "string") throw new Error("Invalid reset response");
|
||||
setInstructions(data.value);
|
||||
setSaveResult("success");
|
||||
} catch {
|
||||
setSaveResult("error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(instructions);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
setLoadError("Clipboard access failed. Select and copy the instructions manually.");
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="animate-subtle-pulse text-text-muted">Loading instructions...</div>;
|
||||
}
|
||||
|
||||
if (loadError && !instructions) {
|
||||
return (
|
||||
<div role="alert" className="rounded-xl border border-error/30 bg-error-bg p-4 text-error">
|
||||
<p>{loadError}</p>
|
||||
<button onClick={() => void loadInstructions()} className="mt-3 min-h-10 rounded-lg border border-error/40 px-4 text-sm font-bold">Retry</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{loadError && <p role="alert" className="rounded-lg bg-error-bg p-3 text-sm text-error">{loadError}</p>}
|
||||
<p className="text-sm text-text-secondary">
|
||||
Copy these instructions and paste them into an LLM chat along with your study material. The LLM will generate JSON you can paste into the Import tab.
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
value={instructions}
|
||||
onChange={(event) => setInstructions(event.target.value)}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
rows={14}
|
||||
className="w-full px-4 py-3 rounded-lg border border-border bg-bg-surface-alt/50 text-sm text-text-body font-mono leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200 resize-y"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -134,9 +134,6 @@ export function ImportTab({ classId, importType, onImported }: ImportTabProps) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (importType === "flashcards") {
|
||||
window.dispatchEvent(new Event("study-decks-changed"));
|
||||
}
|
||||
onImported();
|
||||
} catch {
|
||||
setError("Network error during import");
|
||||
|
|
@ -173,12 +170,12 @@ export function ImportTab({ classId, importType, onImported }: ImportTabProps) {
|
|||
onChange={(e) => setSelectedGroupId(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200"
|
||||
>
|
||||
<option value="">Uncategorized</option>
|
||||
{materialGroups.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="">Uncategorized</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,16 +2,15 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { QuizResults } from "./QuizResults";
|
||||
import type { QuizAttempt, QuizSummary } from "@/types/study";
|
||||
|
||||
interface AttemptHistoryProps {
|
||||
quiz: QuizSummary;
|
||||
attempts: QuizAttempt[];
|
||||
quiz: any;
|
||||
attempts: any[];
|
||||
onRetake: (missedIds: string[]) => void;
|
||||
}
|
||||
|
||||
export function AttemptHistory({ quiz, attempts, onRetake }: AttemptHistoryProps) {
|
||||
const [selectedAttempt, setSelectedAttempt] = useState<QuizAttempt | null>(null);
|
||||
const [selectedAttempt, setSelectedAttempt] = useState<any | null>(null);
|
||||
|
||||
if (selectedAttempt) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,20 +2,20 @@
|
|||
|
||||
import { Fragment } from "react";
|
||||
import { scoreQuestion } from "@/lib/scoring";
|
||||
import type { QuizSummary } from "@/types/study";
|
||||
|
||||
interface CategoryBreakdownProps {
|
||||
quiz: QuizSummary;
|
||||
quiz: any;
|
||||
attempt: any;
|
||||
answeredIds: string[];
|
||||
answers: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export function CategoryBreakdown({ quiz, answeredIds, answers }: CategoryBreakdownProps) {
|
||||
export function CategoryBreakdown({ quiz, attempt, answeredIds, answers }: CategoryBreakdownProps) {
|
||||
// Aggregate stats per category
|
||||
const stats: Record<string, { earned: number; possible: number }> = {};
|
||||
|
||||
answeredIds.forEach((qId) => {
|
||||
const q = quiz.questions.find((x) => x.id === qId);
|
||||
const q = quiz.questions.find((x: any) => x.id === qId);
|
||||
if (!q) return;
|
||||
|
||||
const cat = q.category.trim().toLowerCase();
|
||||
|
|
@ -23,11 +23,7 @@ export function CategoryBreakdown({ quiz, answeredIds, answers }: CategoryBreakd
|
|||
stats[cat] = { earned: 0, possible: 0 };
|
||||
}
|
||||
|
||||
const formattedQ = {
|
||||
id: q.id,
|
||||
type: q.type as "MULTIPLE_CHOICE" | "SATA",
|
||||
options: q.options,
|
||||
};
|
||||
const formattedQ = { id: q.id, type: q.type, options: q.options };
|
||||
const points = scoreQuestion(formattedQ, answers[q.id] || []);
|
||||
|
||||
stats[cat].earned += points;
|
||||
|
|
|
|||
|
|
@ -4,54 +4,34 @@ import ReactMarkdown from "react-markdown";
|
|||
import remarkGfm from "remark-gfm";
|
||||
import { CategoryBreakdown } from "./CategoryBreakdown";
|
||||
import { scoreQuestion } from "@/lib/scoring";
|
||||
import type { QuizAttempt, QuizQuestion, QuizSummary } from "@/types/study";
|
||||
import { parseQuizReviewSnapshot } from "@/lib/quizSnapshots";
|
||||
|
||||
interface QuizResultsProps {
|
||||
quiz: QuizSummary;
|
||||
attempt: QuizAttempt;
|
||||
quiz: any; // QuizData
|
||||
attempt: any; // QuizAttempt
|
||||
onRetake: (missedIds: string[]) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
interface ReviewItem {
|
||||
question: QuizQuestion;
|
||||
score: number;
|
||||
selections: string[];
|
||||
}
|
||||
|
||||
export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsProps) {
|
||||
const snapshot = parseQuizReviewSnapshot(attempt.reviewSnapshotJson);
|
||||
let answers: Record<string, string[]> = {};
|
||||
try {
|
||||
answers = JSON.parse(attempt.answersJson);
|
||||
} catch {}
|
||||
|
||||
if (snapshot) {
|
||||
answers = Object.fromEntries(
|
||||
snapshot.questions.map((question) => [question.id, question.selections])
|
||||
);
|
||||
}
|
||||
|
||||
// Determine non-full-credit questions for review list
|
||||
const reviewItems: ReviewItem[] = [];
|
||||
const reviewItems: any[] = [];
|
||||
const missedIds: string[] = [];
|
||||
|
||||
const answeredIds = Object.keys(answers);
|
||||
const questionsToReview = snapshot?.questions ??
|
||||
quiz.questions.filter((q) => answeredIds.includes(q.id));
|
||||
const snapshotPoints = new Map(
|
||||
snapshot?.questions.map((question) => [question.id, question.points]) ?? []
|
||||
);
|
||||
const questionsToReview = quiz.questions.filter((q: any) => answeredIds.includes(q.id));
|
||||
|
||||
questionsToReview.forEach((q) => {
|
||||
questionsToReview.forEach((q: any) => {
|
||||
const formattedQ = {
|
||||
id: q.id,
|
||||
type: q.type as "MULTIPLE_CHOICE" | "SATA",
|
||||
type: q.type,
|
||||
options: q.options,
|
||||
};
|
||||
const score = snapshotPoints.get(q.id) ??
|
||||
scoreQuestion(formattedQ, answers[q.id] || []);
|
||||
const score = scoreQuestion(formattedQ, answers[q.id] || []);
|
||||
|
||||
// If not full credit (1.0), add to review and missed arrays
|
||||
if (score < 1) {
|
||||
|
|
@ -63,9 +43,6 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
});
|
||||
}
|
||||
});
|
||||
const liveQuestionIds = new Set(quiz.questions.map((question) => question.id));
|
||||
const retakeableMissedIds = missedIds.filter((id) => liveQuestionIds.has(id));
|
||||
const reviewQuiz = { ...quiz, questions: questionsToReview };
|
||||
|
||||
const percentage = attempt.maxScore > 0 ? (attempt.score / attempt.maxScore) * 100 : 0;
|
||||
|
||||
|
|
@ -104,18 +81,18 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
</button>
|
||||
)}
|
||||
|
||||
{retakeableMissedIds.length > 0 && (
|
||||
{missedIds.length > 0 && (
|
||||
<button
|
||||
onClick={() => onRetake(retakeableMissedIds)}
|
||||
onClick={() => onRetake(missedIds)}
|
||||
className="px-6 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
Retake Missed ({retakeableMissedIds.length})
|
||||
Retake Missed ({missedIds.length})
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const allIds = quiz.questions.map((q) => q.id).sort(() => Math.random() - 0.5);
|
||||
const allIds = quiz.questions.map((q: any) => q.id).sort(() => Math.random() - 0.5);
|
||||
onRetake(allIds);
|
||||
}}
|
||||
className="px-6 py-2.5 rounded-lg border border-primary text-primary font-medium hover:bg-primary/5 transition-all duration-200 cursor-pointer"
|
||||
|
|
@ -128,7 +105,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
{/* Category Breakdown */}
|
||||
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-6 md:p-8">
|
||||
<h3 className="text-lg font-bold text-text-heading mb-6">Category Breakdown</h3>
|
||||
<CategoryBreakdown quiz={reviewQuiz} answeredIds={answeredIds} answers={answers} />
|
||||
<CategoryBreakdown quiz={quiz} attempt={attempt} answeredIds={answeredIds} answers={answers} />
|
||||
</div>
|
||||
|
||||
{/* Review List */}
|
||||
|
|
@ -136,7 +113,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
<div className="space-y-6">
|
||||
<h3 className="text-xl font-bold text-text-heading px-2">Areas for Review</h3>
|
||||
|
||||
{reviewItems.map((item) => {
|
||||
{reviewItems.map((item, idx) => {
|
||||
const q = item.question;
|
||||
const sels = item.selections;
|
||||
return (
|
||||
|
|
@ -151,7 +128,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
</div>
|
||||
|
||||
<div className="space-y-2 mb-6">
|
||||
{q.options.map((opt) => {
|
||||
{q.options.map((opt: any) => {
|
||||
const isSelected = sels.includes(opt.id);
|
||||
let stateClass = "cursor-default opacity-80";
|
||||
let icon = null;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { scoreQuestion } from "@/lib/scoring";
|
||||
import { QuizResults } from "./QuizResults";
|
||||
import { recordStudyActivity } from "@/lib/activityClient";
|
||||
import type { QuizAttempt } from "@/types/study";
|
||||
import { normalizeProgress } from "@/lib/progressNormalization";
|
||||
|
||||
interface Option {
|
||||
id: string;
|
||||
|
|
@ -34,52 +31,23 @@ interface QuizViewerProps {
|
|||
currentIndex: number;
|
||||
orderJson: string;
|
||||
answersJson: string | null;
|
||||
sessionId: string;
|
||||
revision: number;
|
||||
}>;
|
||||
};
|
||||
retakeIds: string[] | null;
|
||||
sessionKey?: string | number;
|
||||
storageNamespace?: string;
|
||||
isShared?: boolean;
|
||||
onFinished?: () => void;
|
||||
}
|
||||
|
||||
export function QuizViewer({
|
||||
quiz,
|
||||
retakeIds,
|
||||
sessionKey = "default",
|
||||
storageNamespace,
|
||||
isShared = false,
|
||||
onFinished,
|
||||
}: QuizViewerProps) {
|
||||
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());
|
||||
export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: QuizViewerProps) {
|
||||
const [order, setOrder] = useState<string[]>([]);
|
||||
const [attemptScope, setAttemptScope] = useState<string[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState<Record<string, string[]>>({});
|
||||
const [submittedAnswers, setSubmittedAnswers] = useState<string[]>([]);
|
||||
const [shuffledOptions, setShuffledOptions] = useState<Record<string, Option[]>>({});
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resultsData, setResultsData] = useState<QuizAttempt | null>(null);
|
||||
const [resultsData, setResultsData] = useState<any | null>(null);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
const [isFinishing, setIsFinishing] = useState(false);
|
||||
const [finishError, setFinishError] = useState<string | null>(null);
|
||||
const [restoreWarning, setRestoreWarning] = useState<string | null>(null);
|
||||
const sessionIdentity = `${quiz.id}:${sessionKey}`;
|
||||
const sharedStorageKey = `quiz_progress_${storageNamespace ?? "shared"}_${quiz.id}`;
|
||||
|
||||
// Auto-hide toast
|
||||
useEffect(() => {
|
||||
|
|
@ -91,18 +59,16 @@ export function QuizViewer({
|
|||
|
||||
// Initialize session
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
// Generate shuffled options once per session mount
|
||||
const newShuffledOpts: Record<string, Option[]> = {};
|
||||
for (const q of initialQuiz.questions) {
|
||||
for (const q of quiz.questions) {
|
||||
newShuffledOpts[q.id] = [...q.options].sort(() => Math.random() - 0.5);
|
||||
}
|
||||
setShuffledOptions(newShuffledOpts);
|
||||
|
||||
// If retaking specific questions
|
||||
if (initialRetakeIds && initialRetakeIds.length > 0) {
|
||||
setOrder(initialRetakeIds);
|
||||
setAttemptScope(initialRetakeIds);
|
||||
if (retakeIds && retakeIds.length > 0) {
|
||||
setOrder(retakeIds);
|
||||
setCurrentIndex(0);
|
||||
setAnswers({});
|
||||
setSubmittedAnswers([]);
|
||||
|
|
@ -113,120 +79,62 @@ export function QuizViewer({
|
|||
// Shared links: load from localStorage
|
||||
if (isShared) {
|
||||
try {
|
||||
const saved = localStorage.getItem(sharedStorageKey);
|
||||
const saved = localStorage.getItem(`quiz_progress_${quiz.id}`);
|
||||
if (saved) {
|
||||
const parsed: unknown = JSON.parse(saved);
|
||||
const record =
|
||||
typeof parsed === "object" && parsed !== null
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
const liveIds = initialQuiz.questions.map((question) => question.id);
|
||||
const optionsByQuestion = new Map(
|
||||
initialQuiz.questions.map((question) => [
|
||||
question.id,
|
||||
new Set(question.options.map((option) => option.id)),
|
||||
])
|
||||
);
|
||||
const restored = normalizeProgress({
|
||||
orderJson: record.order,
|
||||
currentIndex: record.currentIndex,
|
||||
dataJson: record.answers,
|
||||
liveIds,
|
||||
isValidValue: (value, questionId): value is string[] =>
|
||||
Array.isArray(value) &&
|
||||
new Set(value).size === value.length &&
|
||||
value.every(
|
||||
(optionId) =>
|
||||
typeof optionId === "string" &&
|
||||
optionsByQuestion.get(questionId)?.has(optionId) === true
|
||||
),
|
||||
});
|
||||
const restoredSet = new Set(restored.order);
|
||||
const restoredOrder = [
|
||||
...restored.order,
|
||||
...liveIds.filter((id) => !restoredSet.has(id)),
|
||||
];
|
||||
setOrder(restoredOrder);
|
||||
setAttemptScope(restoredOrder);
|
||||
setCurrentIndex(
|
||||
restoredOrder.length === 0
|
||||
? 0
|
||||
: Math.min(restored.currentIndex, restoredOrder.length - 1)
|
||||
);
|
||||
setAnswers(restored.data);
|
||||
setSubmittedAnswers(Object.keys(restored.data));
|
||||
if (restored.wasRecovered || restoredOrder.length !== restored.order.length) {
|
||||
setRestoreWarning(
|
||||
"Some saved quiz progress was invalid or referenced changed questions, so it was safely repaired."
|
||||
);
|
||||
}
|
||||
const parsed = JSON.parse(saved);
|
||||
setOrder(parsed.order || quiz.questions.map(q => q.id));
|
||||
setCurrentIndex(parsed.currentIndex || 0);
|
||||
setAnswers(parsed.answers || {});
|
||||
setSubmittedAnswers(Object.keys(parsed.answers || {}));
|
||||
setToastMessage("Session restored");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
// Fallback to defaults
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, normal sequential logic checking for progress
|
||||
const prog = initialProgress;
|
||||
const prog = quiz.progress?.find((p) => p.mode === "SEQUENTIAL");
|
||||
if (prog) {
|
||||
const restored = normalizeProgress({
|
||||
orderJson: prog.orderJson,
|
||||
currentIndex: prog.currentIndex,
|
||||
dataJson: prog.answersJson,
|
||||
liveIds: initialQuiz.questions.map((question) => question.id),
|
||||
isValidValue: (value, questionId): value is string[] => {
|
||||
const question = initialQuiz.questions.find((item) => item.id === questionId);
|
||||
const optionIds = new Set(question?.options.map((option) => option.id) ?? []);
|
||||
return Array.isArray(value) &&
|
||||
new Set(value).size === value.length &&
|
||||
value.every((id) => typeof id === "string" && optionIds.has(id));
|
||||
},
|
||||
});
|
||||
const liveIds = initialQuiz.questions.map((question) => question.id);
|
||||
const restoredSet = new Set(restored.order);
|
||||
const restoredOrder = [
|
||||
...restored.order,
|
||||
...liveIds.filter((id) => !restoredSet.has(id)),
|
||||
];
|
||||
setOrder(restoredOrder);
|
||||
setAttemptScope(restoredOrder);
|
||||
setCurrentIndex(
|
||||
restoredOrder.length === 0
|
||||
? 0
|
||||
: Math.min(restored.currentIndex, restoredOrder.length - 1)
|
||||
);
|
||||
setAnswers(restored.data);
|
||||
setSubmittedAnswers(Object.keys(restored.data));
|
||||
if (restored.wasRecovered || restoredOrder.length !== restored.order.length) {
|
||||
setRestoreWarning(
|
||||
"Some saved quiz progress was invalid or referenced deleted questions, so it was safely repaired."
|
||||
);
|
||||
let savedOrder: string[] = [];
|
||||
try {
|
||||
savedOrder = JSON.parse(prog.orderJson);
|
||||
} catch {
|
||||
savedOrder = quiz.questions.map((q) => q.id);
|
||||
}
|
||||
|
||||
let savedAnswers: Record<string, string[]> = {};
|
||||
if (prog.answersJson) {
|
||||
try {
|
||||
savedAnswers = JSON.parse(prog.answersJson);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
setOrder(savedOrder);
|
||||
setCurrentIndex(Math.min(prog.currentIndex, savedOrder.length - 1));
|
||||
setAnswers(savedAnswers);
|
||||
setSubmittedAnswers(Object.keys(savedAnswers));
|
||||
} else {
|
||||
const freshOrder = initialQuiz.questions.map((q) => q.id).sort(() => Math.random() - 0.5);
|
||||
const freshOrder = quiz.questions.map((q) => q.id).sort(() => Math.random() - 0.5);
|
||||
setOrder(freshOrder);
|
||||
setAttemptScope(freshOrder);
|
||||
setCurrentIndex(0);
|
||||
setAnswers({});
|
||||
setSubmittedAnswers([]);
|
||||
}
|
||||
setLoading(false);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [sessionIdentity, isShared, sharedStorageKey, initialQuiz, initialRetakeIds, initialProgress]);
|
||||
}, [quiz, retakeIds, isShared]);
|
||||
|
||||
// Persist progress as you go
|
||||
function saveProgress(
|
||||
idx: number,
|
||||
ans: Record<string, string[]>
|
||||
) {
|
||||
if (attemptScope.length !== quiz.questions.length) return;
|
||||
if (retakeIds) return; // Don't persist retake partial state
|
||||
|
||||
if (isShared) {
|
||||
localStorage.setItem(sharedStorageKey, JSON.stringify({
|
||||
localStorage.setItem(`quiz_progress_${quiz.id}`, JSON.stringify({
|
||||
currentIndex: idx,
|
||||
order: order,
|
||||
answers: ans,
|
||||
|
|
@ -234,10 +142,7 @@ export function QuizViewer({
|
|||
return;
|
||||
}
|
||||
|
||||
const revision = ++progressRevisionRef.current;
|
||||
saveQueueRef.current = saveQueueRef.current
|
||||
.then(async () => {
|
||||
const response = await fetch("/api/progress", {
|
||||
fetch("/api/progress", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
|
|
@ -245,21 +150,10 @@ export function QuizViewer({
|
|||
contentId: quiz.id,
|
||||
mode: "SEQUENTIAL",
|
||||
currentIndex: idx,
|
||||
order,
|
||||
answers: ans,
|
||||
sessionId: progressSessionIdRef.current,
|
||||
revision,
|
||||
orderJson: JSON.stringify(order),
|
||||
answersJson: JSON.stringify(ans),
|
||||
}),
|
||||
});
|
||||
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.");
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
if (loading) return <div>Loading quiz...</div>;
|
||||
|
|
@ -300,11 +194,7 @@ export function QuizViewer({
|
|||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (isSubmitted || !currentQId || submittingQuestionRef.current === currentQId) return;
|
||||
submittingQuestionRef.current = currentQId;
|
||||
if (!isShared) {
|
||||
recordStudyActivity("QUIZ_QUESTION").catch(() => {});
|
||||
}
|
||||
if (isSubmitted || !currentQId) return;
|
||||
const newSubmitted = [...submittedAnswers, currentQId];
|
||||
setSubmittedAnswers(newSubmitted);
|
||||
saveProgress(currentIndex, answers);
|
||||
|
|
@ -312,7 +202,6 @@ export function QuizViewer({
|
|||
|
||||
function handleNext() {
|
||||
if (currentIndex + 1 < order.length) {
|
||||
submittingQuestionRef.current = null;
|
||||
const nextIdx = currentIndex + 1;
|
||||
setCurrentIndex(nextIdx);
|
||||
saveProgress(nextIdx, answers);
|
||||
|
|
@ -320,21 +209,18 @@ export function QuizViewer({
|
|||
}
|
||||
|
||||
async function handleFinish() {
|
||||
if (finishingRef.current) return;
|
||||
finishingRef.current = true;
|
||||
setIsFinishing(true);
|
||||
setFinishError(null);
|
||||
if (isShared) {
|
||||
localStorage.removeItem(sharedStorageKey);
|
||||
localStorage.removeItem(`quiz_progress_${quiz.id}`);
|
||||
}
|
||||
|
||||
// If shared, grade it locally and show results, don't ping backend
|
||||
if (isShared) {
|
||||
// Simulate an attempt object
|
||||
let runningScore = 0;
|
||||
const maxScore = attemptScope.length;
|
||||
let maxScore = quiz.questions.length; // Actually, depends on retakeIds, but shared doesn't support retakeIds typically
|
||||
if (retakeIds) maxScore = retakeIds.length;
|
||||
|
||||
const scoredItems = attemptScope.map(qId => {
|
||||
const scoredItems = (retakeIds || quiz.questions.map(q => q.id)).map(qId => {
|
||||
const q = quiz.questions.find((x) => x.id === qId);
|
||||
if (!q) return 0;
|
||||
const formattedQ = {
|
||||
|
|
@ -351,40 +237,45 @@ export function QuizViewer({
|
|||
score: runningScore,
|
||||
maxScore: maxScore,
|
||||
answersJson: JSON.stringify(answers),
|
||||
isPartialRetake: attemptScope.length !== quiz.questions.length,
|
||||
isPartialRetake: false,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
setResultsData(attempt);
|
||||
finishingRef.current = false;
|
||||
setIsFinishing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send to backend
|
||||
const isPartialRetake = !!retakeIds;
|
||||
try {
|
||||
await saveQueueRef.current;
|
||||
const res = await fetch(`/api/quizzes/${quiz.id}/attempt`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
questionIds: attemptScope,
|
||||
answers,
|
||||
answersJson: JSON.stringify(answers),
|
||||
isPartialRetake,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const payload = (await res.json().catch(() => null)) as
|
||||
| { error?: string }
|
||||
| null;
|
||||
throw new Error(payload?.error ?? "Could not save this attempt");
|
||||
if (res.ok) {
|
||||
const attempt = await res.json();
|
||||
|
||||
// Clear progress
|
||||
if (!isPartialRetake) {
|
||||
await fetch("/api/progress", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contentType: "QUIZ",
|
||||
contentId: quiz.id,
|
||||
mode: "SEQUENTIAL",
|
||||
}),
|
||||
});
|
||||
}
|
||||
setResultsData(await res.json());
|
||||
} catch (error) {
|
||||
setFinishError(
|
||||
error instanceof Error ? error.message : "Could not save this attempt"
|
||||
);
|
||||
} finally {
|
||||
finishingRef.current = false;
|
||||
setIsFinishing(false);
|
||||
|
||||
setResultsData(attempt);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -396,13 +287,10 @@ export function QuizViewer({
|
|||
onRetake={(missedIds: string[]) => {
|
||||
// Restart this component with retakeIds
|
||||
setOrder(missedIds);
|
||||
setAttemptScope(missedIds);
|
||||
setCurrentIndex(0);
|
||||
setAnswers({});
|
||||
setSubmittedAnswers([]);
|
||||
submittingQuestionRef.current = null;
|
||||
setResultsData(null);
|
||||
setFinishError(null);
|
||||
|
||||
// Re-shuffle options for the new attempt
|
||||
const newShuffledOpts: Record<string, Option[]> = {};
|
||||
|
|
@ -422,11 +310,6 @@ export function QuizViewer({
|
|||
|
||||
return (
|
||||
<div className="flex flex-col items-center relative">
|
||||
{restoreWarning && (
|
||||
<div className="mb-4 w-full max-w-4xl rounded-xl bg-badge-bg px-4 py-3 text-sm text-text-secondary" role="status">
|
||||
{restoreWarning}
|
||||
</div>
|
||||
)}
|
||||
{/* Toast Notification */}
|
||||
{toastMessage && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-30 animate-fade-in pointer-events-none">
|
||||
|
|
@ -459,11 +342,11 @@ export function QuizViewer({
|
|||
<div className="px-5 pb-6 pt-6 md:px-10 md:pb-10 md:pt-8">
|
||||
|
||||
{/* Tags */}
|
||||
<div className="mb-8 flex items-start justify-between gap-3">
|
||||
<span className="inline-flex w-fit min-w-0 max-w-[48%] px-3 py-1 rounded-md bg-primary/10 text-primary text-xs font-bold uppercase tracking-wider leading-snug whitespace-normal">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<span className="inline-flex px-3 py-1 rounded-md bg-primary/10 text-primary text-xs font-bold uppercase tracking-wider">
|
||||
{currentQ.category}
|
||||
</span>
|
||||
<span className="inline-flex w-fit min-w-0 max-w-[48%] justify-end px-3 py-1 rounded-md bg-amber-500/10 text-right text-amber-600 text-xs font-bold uppercase tracking-wider leading-snug whitespace-normal">
|
||||
<span className="inline-flex px-3 py-1 rounded-md bg-amber-500/10 text-amber-600 text-xs font-bold uppercase tracking-wider">
|
||||
{currentQ.type === "MULTIPLE_CHOICE" ? "Multiple Choice" : "Select All That Apply"}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -491,7 +374,7 @@ export function QuizViewer({
|
|||
} else if (!isSelected && opt.isCorrect) {
|
||||
stateClass = "border-success/50 bg-success/5 text-text-heading";
|
||||
rightIcon = (
|
||||
<div className="flex items-center gap-1.5 text-sm font-bold text-success">
|
||||
<div className="text-success flex items-center gap-1.5 text-sm font-bold">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
|
|
@ -509,7 +392,7 @@ export function QuizViewer({
|
|||
<div
|
||||
key={opt.id}
|
||||
onClick={() => toggleOption(opt.id)}
|
||||
className={`grid min-h-14 grid-cols-[auto_minmax(0,1fr)] items-center gap-x-4 gap-y-2 rounded-2xl border px-4 py-4 transition-all duration-200 md:flex md:gap-4 md:px-5 ${stateClass}`}
|
||||
className={`flex min-h-14 items-center gap-4 rounded-2xl border px-4 py-4 transition-all duration-200 md:px-5 ${stateClass}`}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`w-5 h-5 rounded ${currentQ.type === 'SATA' ? 'border' : 'border rounded-full'} flex items-center justify-center transition-colors ${
|
||||
|
|
@ -533,7 +416,7 @@ export function QuizViewer({
|
|||
{opt.text}
|
||||
</div>
|
||||
{rightIcon && (
|
||||
<div className="col-start-2 min-w-0 md:flex-shrink-0 md:pl-2">
|
||||
<div className="flex-shrink-0 pl-2">
|
||||
{rightIcon}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -612,20 +495,12 @@ export function QuizViewer({
|
|||
Next Question
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
{finishError && (
|
||||
<div className="mb-3 rounded-xl bg-error-bg px-4 py-3 text-sm text-error" role="alert">
|
||||
{finishError} Your answers are still here; try again.
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleFinish}
|
||||
disabled={isFinishing}
|
||||
className="w-full py-3.5 rounded-xl bg-success hover:bg-success/90 text-white font-bold text-lg tracking-wide transition-all duration-200 cursor-pointer shadow-sm disabled:cursor-not-allowed disabled:opacity-60"
|
||||
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"
|
||||
>
|
||||
{isFinishing ? "Saving attempt..." : "Finish Quiz"}
|
||||
Finish Quiz
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,374 +0,0 @@
|
|||
"use client";
|
||||
|
||||
/* eslint-disable react-hooks/refs -- @dnd-kit intentionally returns render-time ref callbacks and drag state. */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
pointerWithin,
|
||||
useDraggable,
|
||||
useDndContext,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
interface DeckSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
sortOrder?: number;
|
||||
_count: { cards: number };
|
||||
}
|
||||
|
||||
interface SetSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
newCardsPerDay: number;
|
||||
decks: DeckSummary[];
|
||||
stats: {
|
||||
totalCards: number;
|
||||
reviewedCards: number;
|
||||
unseenCards: number;
|
||||
dueCards: number;
|
||||
learningAheadCards: number;
|
||||
newCardsAvailable: number;
|
||||
availableCards: number;
|
||||
nextDue: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface EditState {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
newCardsPerDay: number;
|
||||
}
|
||||
|
||||
function notifyStudyAvailabilityChanged() {
|
||||
window.dispatchEvent(new Event("spaced-repetition-updated"));
|
||||
}
|
||||
|
||||
function SourceDeck({ deck }: { deck: DeckSummary }) {
|
||||
const drag = useDraggable({
|
||||
id: `source:${deck.id}`,
|
||||
data: { deckId: deck.id, sourceSetId: null },
|
||||
});
|
||||
return (
|
||||
<div
|
||||
ref={drag.setNodeRef}
|
||||
style={{ transform: CSS.Translate.toString(drag.transform), opacity: drag.isDragging ? 0.45 : 1 }}
|
||||
className="flex min-w-0 items-center gap-3 rounded-xl border border-border-light bg-bg-surface p-4 shadow-sm"
|
||||
>
|
||||
<button
|
||||
{...drag.attributes}
|
||||
{...drag.listeners}
|
||||
className="grid h-10 w-10 shrink-0 place-items-center rounded-lg text-text-muted hover:bg-bg-surface-alt hover:text-text-heading"
|
||||
aria-label={`Drag ${deck.name} into a repetition set`}
|
||||
>
|
||||
<span aria-hidden>☷</span>
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-bold text-text-heading">{deck.name}</p>
|
||||
<p className="text-xs text-text-muted">{deck._count.cards} cards</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MembershipDeck({ deck, setId, onRemove }: {
|
||||
deck: DeckSummary;
|
||||
setId: string;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const sortable = useSortable({
|
||||
id: `set:${setId}:deck:${deck.id}`,
|
||||
data: { deckId: deck.id, sourceSetId: setId, setId },
|
||||
});
|
||||
return (
|
||||
<div
|
||||
ref={sortable.setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(sortable.transform),
|
||||
transition: sortable.transition,
|
||||
opacity: sortable.isDragging ? 0.4 : 1,
|
||||
}}
|
||||
className="flex min-w-0 items-center gap-2 rounded-xl border border-border-light bg-bg-surface-alt/55 p-3"
|
||||
>
|
||||
<button
|
||||
{...sortable.attributes}
|
||||
{...sortable.listeners}
|
||||
className="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-text-muted hover:bg-bg-surface hover:text-text-heading"
|
||||
aria-label={`Reorder or copy ${deck.name}`}
|
||||
>
|
||||
<span aria-hidden>☷</span>
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold text-text-heading">{deck.name}</p>
|
||||
<p className="text-xs text-text-muted">{deck._count.cards} cards</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-text-muted hover:bg-error-bg hover:text-error"
|
||||
aria-label={`Remove ${deck.name} from this repetition set`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SetDropZone({ set, children }: { set: SetSummary; children: React.ReactNode }) {
|
||||
const drop = useDroppable({ id: `set:${set.id}`, data: { setId: set.id } });
|
||||
const { over } = useDndContext();
|
||||
const isOver =
|
||||
over?.id === `set:${set.id}` || over?.data.current?.setId === set.id;
|
||||
return (
|
||||
<div
|
||||
ref={drop.setNodeRef}
|
||||
className={`grid min-h-20 grid-cols-1 gap-3 rounded-xl border-2 border-dashed p-3 sm:grid-cols-2 ${
|
||||
isOver ? "border-primary bg-primary/5" : "border-border-light"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpacedRepetitionSets() {
|
||||
const { classSlug } = useParams<{ classSlug: string }>();
|
||||
const [classId, setClassId] = useState("");
|
||||
const [sets, setSets] = useState<SetSummary[]>([]);
|
||||
const [decks, setDecks] = useState<DeckSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [editing, setEditing] = useState<EditState | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const classesResponse = await fetch("/api/classes");
|
||||
const classes = (await classesResponse.json()) as Array<{ id: string; slug: string }>;
|
||||
const currentClass = classes.find((item) => item.slug === classSlug);
|
||||
if (!currentClass) throw new Error("Class not found");
|
||||
setClassId(currentClass.id);
|
||||
const [setsResponse, decksResponse] = await Promise.all([
|
||||
fetch(`/api/spaced-repetition-sets?classId=${currentClass.id}`),
|
||||
fetch(`/api/decks/list?classId=${currentClass.id}`),
|
||||
]);
|
||||
if (!setsResponse.ok || !decksResponse.ok) throw new Error("Unable to load repetition sets");
|
||||
setSets(await setsResponse.json());
|
||||
setDecks(await decksResponse.json());
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "Unable to load repetition sets");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [classSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => void load(), 0);
|
||||
function refreshWhenVisible() {
|
||||
if (document.visibilityState === "visible") void load();
|
||||
}
|
||||
window.addEventListener("focus", refreshWhenVisible);
|
||||
window.addEventListener("study-decks-changed", refreshWhenVisible);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener("focus", refreshWhenVisible);
|
||||
window.removeEventListener("study-decks-changed", refreshWhenVisible);
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
async function createSet() {
|
||||
if (!newName.trim()) return;
|
||||
const response = await fetch("/api/spaced-repetition-sets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ classId, name: newName, newCardsPerDay: 30 }),
|
||||
});
|
||||
if (!response.ok) return setMessage("Could not create repetition set");
|
||||
setNewName("");
|
||||
setCreating(false);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editing) return;
|
||||
const response = await fetch(`/api/spaced-repetition-sets/${editing.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: editing.name,
|
||||
description: editing.description,
|
||||
newCardsPerDay: editing.newCardsPerDay,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) return setMessage("Could not update repetition set");
|
||||
setEditing(null);
|
||||
notifyStudyAvailabilityChanged();
|
||||
await load();
|
||||
}
|
||||
|
||||
async function deleteSet(set: SetSummary) {
|
||||
if (!confirm(`Delete “${set.name}”? Its spaced-repetition schedules will be permanently removed.`)) return;
|
||||
const response = await fetch(`/api/spaced-repetition-sets/${set.id}`, { method: "DELETE" });
|
||||
if (!response.ok) return setMessage("Could not delete repetition set");
|
||||
notifyStudyAvailabilityChanged();
|
||||
await load();
|
||||
}
|
||||
|
||||
async function removeDeck(set: SetSummary, deck: DeckSummary) {
|
||||
if (!confirm(`Remove “${deck.name}” from “${set.name}”? Its scheduling history in this set will be reset.`)) return;
|
||||
const response = await fetch(`/api/spaced-repetition-sets/${set.id}/decks/${deck.id}`, { method: "DELETE" });
|
||||
if (!response.ok) return setMessage("Could not remove deck");
|
||||
notifyStudyAvailabilityChanged();
|
||||
await load();
|
||||
}
|
||||
|
||||
async function addDeck(setId: string, deckId: string) {
|
||||
const target = sets.find((set) => set.id === setId);
|
||||
const deck = decks.find((item) => item.id === deckId);
|
||||
if (target?.decks.some((item) => item.id === deckId)) {
|
||||
setMessage(`${deck?.name ?? "That deck"} is already in ${target.name}.`);
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`/api/spaced-repetition-sets/${setId}/decks`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ deckId }),
|
||||
});
|
||||
if (!response.ok) return setMessage((await response.json()).error ?? "Could not add deck");
|
||||
setMessage(`${deck?.name ?? "Deck"} added.`);
|
||||
notifyStudyAvailabilityChanged();
|
||||
await load();
|
||||
}
|
||||
|
||||
async function handleDragEnd(event: DragEndEvent) {
|
||||
const activeData = event.active.data.current as { deckId?: string; sourceSetId?: string | null } | undefined;
|
||||
const overData = event.over?.data.current as { setId?: string; deckId?: string } | undefined;
|
||||
if (!activeData?.deckId || !overData?.setId) return;
|
||||
const targetSetId = overData.setId;
|
||||
|
||||
if (activeData.sourceSetId === targetSetId && overData.deckId) {
|
||||
const target = sets.find((set) => set.id === targetSetId);
|
||||
if (!target) return;
|
||||
const oldIndex = target.decks.findIndex((deck) => deck.id === activeData.deckId);
|
||||
const newIndex = target.decks.findIndex((deck) => deck.id === overData.deckId);
|
||||
if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return;
|
||||
const ordered = arrayMove(target.decks, oldIndex, newIndex);
|
||||
setSets((current) => current.map((set) => set.id === targetSetId ? { ...set, decks: ordered } : set));
|
||||
const response = await fetch(`/api/spaced-repetition-sets/${targetSetId}/decks`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ deckIds: ordered.map((deck) => deck.id) }),
|
||||
});
|
||||
if (!response.ok) await load();
|
||||
return;
|
||||
}
|
||||
await addDeck(targetSetId, activeData.deckId);
|
||||
}
|
||||
|
||||
if (loading) return <div className="animate-subtle-pulse py-16 text-center text-text-muted">Loading repetition sets…</div>;
|
||||
|
||||
return (
|
||||
<div className="pb-20">
|
||||
<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">Adaptive review</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Spaced repetition</h2></div>
|
||||
<button onClick={() => setCreating(true)} className="min-h-12 rounded-xl bg-primary px-5 font-bold text-white shadow-sm hover:bg-primary-hover">New repetition set</button>
|
||||
</div>
|
||||
|
||||
{message && <div className="mb-5 flex items-center justify-between rounded-xl border border-primary/20 bg-bg-callout px-4 py-3 text-sm text-text-heading"><span>{message}</span><button onClick={() => setMessage(null)} aria-label="Dismiss message">×</button></div>}
|
||||
|
||||
{creating && (
|
||||
<div className="mb-6 flex flex-col gap-3 rounded-2xl border border-primary/20 bg-bg-surface p-5 sm:flex-row">
|
||||
<input autoFocus value={newName} onChange={(event) => setNewName(event.target.value)} onKeyDown={(event) => event.key === "Enter" && createSet()} placeholder="e.g. Patho comprehensive review" className="min-h-11 flex-1 rounded-lg border border-border bg-bg-surface-alt px-3 text-text-heading" />
|
||||
<button onClick={createSet} className="rounded-lg bg-primary px-5 font-bold text-white">Create</button>
|
||||
<button onClick={() => { setCreating(false); setNewName(""); }} className="rounded-lg border border-border px-5 font-bold text-text-heading">Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragEnd={handleDragEnd}>
|
||||
<div className="space-y-6">
|
||||
{sets.length === 0 && <div className="rounded-2xl border border-dashed border-border p-10 text-center"><h3 className="font-bold text-text-heading">No repetition sets yet</h3><p className="mt-1 text-sm text-text-secondary">Create one, then drag flashcard decks into it.</p></div>}
|
||||
{sets.map((set) => (
|
||||
<section key={set.id} className="rounded-2xl border border-border-light bg-bg-surface/75 p-4 shadow-[var(--shadow-card)] sm:p-5">
|
||||
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-xl font-extrabold text-text-heading">{set.name}</h3>
|
||||
{set.description && <p className="mt-1 text-sm text-text-secondary">{set.description}</p>}
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs">
|
||||
<span className="rounded-full bg-badge-bg px-2.5 py-1 text-badge-text">{set.stats.totalCards} cards</span>
|
||||
<span className="rounded-full bg-error-bg px-2.5 py-1 text-error">{set.stats.dueCards} due</span>
|
||||
{set.stats.learningAheadCards > 0 && <span className="rounded-full bg-bg-callout px-2.5 py-1 text-primary">{set.stats.learningAheadCards} learning</span>}
|
||||
<span className="rounded-full bg-success-bg px-2.5 py-1 text-success">{set.stats.newCardsAvailable} new today</span>
|
||||
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1 text-text-muted">Limit {set.newCardsPerDay}/day</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{set.stats.totalCards === 0 ? <button disabled className="min-h-10 rounded-lg bg-bg-surface-alt px-5 text-sm font-bold text-text-muted">Add decks to study</button> : set.stats.availableCards > 0 ? (
|
||||
<Link href={`/${classSlug}/spaced-repetition/${set.id}`} className="inline-flex min-h-10 items-center rounded-lg bg-primary px-5 text-sm font-bold text-white hover:bg-primary-hover">{set.stats.reviewedCards === 0 ? "Study" : "Continue"} · {set.stats.availableCards}</Link>
|
||||
) : <Link href={`/${classSlug}/spaced-repetition/${set.id}`} className="inline-flex min-h-10 items-center rounded-lg bg-success-bg px-5 text-sm font-bold text-success">Caught up</Link>}
|
||||
<button onClick={() => setEditing({ id: set.id, name: set.name, description: set.description ?? "", newCardsPerDay: set.newCardsPerDay })} className="grid h-10 w-10 place-items-center rounded-lg text-text-muted hover:bg-bg-surface-alt hover:text-text-heading" aria-label={`Edit ${set.name}`}>✎</button>
|
||||
<button onClick={() => deleteSet(set)} className="grid h-10 w-10 place-items-center rounded-lg text-text-muted hover:bg-error-bg hover:text-error" aria-label={`Delete ${set.name}`}>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
|
||||
<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>
|
||||
<SortableContext items={set.decks.map((deck) => `set:${set.id}:deck:${deck.id}`)} strategy={verticalListSortingStrategy}>
|
||||
<SetDropZone set={set}>
|
||||
{set.decks.length === 0 ? <p className="col-span-full py-4 text-center text-sm text-text-muted">Drop flashcard decks here</p> : set.decks.map((deck) => <MembershipDeck key={deck.id} deck={deck} setId={set.id} onRemove={() => removeDeck(set, deck)} />)}
|
||||
</SetDropZone>
|
||||
</SortableContext>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<section className="mt-10 border-t border-border-light pt-8">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Source library</p>
|
||||
<h3 className="editorial-title mt-1 text-2xl text-text-heading">Flashcard decks</h3>
|
||||
<p className="mb-5 mt-1 text-sm text-text-secondary">Drag a deck into any repetition set. The original deck stays here and can be reused.</p>
|
||||
{decks.length ? <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">{decks.map((deck) => <SourceDeck key={deck.id} deck={deck} />)}</div> : <div className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-text-muted">Create flashcard decks before building a repetition set.</div>}
|
||||
</section>
|
||||
</div>
|
||||
</DndContext>
|
||||
|
||||
{editing && (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center bg-black/45 p-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md rounded-3xl border border-border bg-bg-surface p-6 shadow-[var(--shadow-modal)]">
|
||||
<h3 className="editorial-title text-2xl text-text-heading">Edit repetition set</h3>
|
||||
<label className="mt-5 block text-sm font-bold text-text-heading">Name</label>
|
||||
<input value={editing.name} onChange={(event) => setEditing({ ...editing, name: event.target.value })} className="mt-1 min-h-11 w-full rounded-lg border border-border bg-bg-surface-alt px-3 text-text-heading" />
|
||||
<label className="mt-4 block text-sm font-bold text-text-heading">Description</label>
|
||||
<textarea value={editing.description} onChange={(event) => setEditing({ ...editing, description: event.target.value })} rows={3} className="mt-1 w-full resize-none rounded-lg border border-border bg-bg-surface-alt p-3 text-text-heading" />
|
||||
<label className="mt-4 block text-sm font-bold text-text-heading">New cards per day</label>
|
||||
<input type="number" min={0} max={999} value={editing.newCardsPerDay} onChange={(event) => setEditing({ ...editing, newCardsPerDay: Number(event.target.value) })} className="mt-1 min-h-11 w-full rounded-lg border border-border bg-bg-surface-alt px-3 text-text-heading" />
|
||||
<p className="mt-1 text-xs text-text-muted">Due reviews are never capped. Set this to 0 to pause new cards.</p>
|
||||
<div className="mt-6 flex justify-end gap-2"><button onClick={() => setEditing(null)} className="min-h-10 rounded-lg border border-border px-4 font-bold text-text-heading">Cancel</button><button onClick={saveEdit} className="min-h-10 rounded-lg bg-primary px-4 font-bold text-white">Save</button></div>
|
||||
</div>
|
||||
</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