Compare commits

...
Sign in to create a new pull request.

13 commits

Author SHA1 Message Date
a6af3a49ad Highlight assisted crossword cells in final results
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m1s
2026-07-14 20:33:39 -07:00
5bec95fb30 Initial crossword addition and arcade redesign
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m41s
2026-07-14 19:32:41 -07:00
611a585757 Initial addition of the Arcade study feature. Add connections as the first working game.
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m4s
2026-07-13 17:35:18 -07:00
7b90409f2e Add deck generation and study flow improvements
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m22s
2026-07-13 14:56:33 -07:00
d6f3502cb1 Tighten typing and input validation across study pages
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m2s
2026-07-13 06:55:17 -07:00
6d1d918355 Improve responsive layout for study and quiz cards
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m14s
2026-07-13 06:32:54 -07:00
e3265f03e2 Remove activity summary stats from banner
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m0s
2026-07-12 12:01:31 -07:00
dad62c9914 Add study activity tracking and refresh class header UI
All checks were successful
Automated Container Build / build-and-push (push) Successful in 54s
2026-07-12 11:44:26 -07:00
95af276d2e Refactor login flow with reset token support
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m6s
2026-07-12 07:53:48 -07:00
f0548bcc6e Fix: share popup not closing when clicking outside menu
All checks were successful
Automated Container Build / build-and-push (push) Successful in 57s
2026-07-11 19:36:17 -07:00
061d13f421 Update AGENTS.md
All checks were successful
Automated Container Build / build-and-push (push) Successful in 59s
2026-07-11 19:29:45 -07:00
c84332d072 Update agents.md, add share icon to main cards
All checks were successful
Automated Container Build / build-and-push (push) Successful in 58s
2026-07-11 19:19:27 -07:00
631dfa5923 New: Ui Redesign 2026-07-11 13:48:02 -07:00
126 changed files with 19484 additions and 868 deletions

6
.gitignore vendored
View file

@ -13,6 +13,12 @@
# testing
/coverage
# local SQLite study data
/dev.db
/dev.db-journal
/dev.db-shm
/dev.db-wal
# next.js
/.next/
/out/

353
AGENTS.md
View file

@ -1,5 +1,350 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
# Study Desk - AI Contributor Guide
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
This file is the operating guide for AI agents and human contributors working in this repository.
## Project summary
Study Desk is a self-hosted, single-user study application built with Next.js, React, TypeScript, Prisma, and SQLite.
Core capabilities:
- 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
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:
1. Current source code
2. `prisma/schema.prisma`
3. Applied migrations
4. This file
5. Older planning documents and the stock README
## Mandatory working rules
- 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.
- Do not commit secrets, `.env` files, database contents, generated credentials, or private study material.
- 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.
## Stack and source of truth
Confirm exact versions in `package.json`. The project currently uses:
- 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 files:
- `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
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.
## Local development
For a clean checkout:
```bash
npm ci
npx prisma generate
npm run dev
```
On Windows PowerShell, use `npm.cmd` and `npx.cmd` if script execution policy blocks the default shims.
Useful scripts:
```bash
npm run dev
npm run build
npm run start
npm run lint
```
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.
## Environment and authentication
Common environment variables:
- `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
Current password behavior:
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.
Do not change password provisioning casually. Any change must update login, setup-status behavior, deployment documentation, and security handling together.
Authentication state is stored in the `study-app-session` cookie.
Public paths include:
- `/login`
- `/api/auth/*`
- `/shared/*`
- Static assets and Next internals
Everything else requires authentication unless explicitly designed and reviewed as public.
Do not log passwords, secrets, full share tokens, or private study content.
## Database and migration safety
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:
```bash
npx prisma generate
npx prisma migrate dev --name describe_the_change
```
Use `npx prisma migrate deploy` for deployed environments.
## Architecture 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.
### API routes and services
Preferred route-handler flow:
1. Parse and validate external input.
2. Delegate business logic to focused service or utility functions.
3. Return a useful status and JSON response.
Business logic and Prisma calls normally belong in `src/services/` or a focused `src/lib/` module. Reuse existing services before creating new abstractions.
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.
### Validation
- 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.
## UI and accessibility
Use the existing design system in `src/app/globals.css`.
Common tokens include:
- `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`
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
### 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.
### Quizzes
- A quiz contains ordered questions 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`.
### Imports and generation
Flashcard import shape:
```json
{
"type": "flashcards",
"deckName": "Deck name",
"description": "Optional description",
"cards": [{ "front": "...", "back": "..." }]
}
```
Quiz import shape:
```json
{
"type": "quiz",
"quizName": "Quiz name",
"description": "Optional description",
"questions": [
{
"type": "multiple_choice",
"prompt": "...",
"category": "topic",
"options": [{ "text": "...", "correct": true }],
"rationale": "..."
}
]
}
```
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`.
### 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.
## Verification checklist
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.
During implementation:
- Keep route, service, utility, and component responsibilities clear.
- 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.
After editing:
```bash
npm run lint
npm run build
```
If lint already fails, report baseline failures separately.
For UI work, manually verify:
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
## 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.
## Extending the application
For a genuinely new study mode:
1. Add or update the Prisma model and migration when persistence is required.
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.
Do not add a new mode by placing unrelated logic into `deckService.ts`, `quizService.ts`, or a large page component.

BIN
dev.db

Binary file not shown.

840
package-lock.json generated
View file

@ -22,6 +22,7 @@
"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": {
@ -33,7 +34,8 @@
"eslint-config-next": "16.2.9",
"prisma": "^7.8.0",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.1.10"
}
},
"node_modules/@alloc/quick-lru": {
@ -1349,6 +1351,16 @@
"node": ">=12.4.0"
}
},
"node_modules/@oxc-project/types": {
"version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@phc/format": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
@ -1737,6 +1749,293 @@
}
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.11.1",
"@napi-rs/wasm-runtime": "^1.1.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@ -2042,6 +2341,17 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@ -2051,6 +2361,13 @@
"@types/ms": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@ -2110,6 +2427,7 @@
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@ -2790,6 +3108,119 @@
"win32"
]
},
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.10",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
@ -3040,6 +3471,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@ -3395,6 +3836,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -4061,6 +4512,13 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
@ -4555,6 +5013,16 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@ -4574,6 +5042,16 @@
"node": ">=6"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/exsolve": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz",
@ -4797,6 +5275,21 @@
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -7675,6 +8168,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/obug": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/ohash": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
@ -8407,6 +8914,40 @@
"node": ">=0.10.0"
}
},
"node_modules/rolldown": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.139.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.1.5",
"@rolldown/binding-darwin-arm64": "1.1.5",
"@rolldown/binding-darwin-x64": "1.1.5",
"@rolldown/binding-freebsd-x64": "1.1.5",
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
"@rolldown/binding-linux-arm64-musl": "1.1.5",
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
"@rolldown/binding-linux-x64-gnu": "1.1.5",
"@rolldown/binding-linux-x64-musl": "1.1.5",
"@rolldown/binding-openharmony-arm64": "1.1.5",
"@rolldown/binding-wasm32-wasi": "1.1.5",
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
"@rolldown/binding-win32-x64-msvc": "1.1.5"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@ -8739,6 +9280,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@ -8833,6 +9381,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
@ -9130,6 +9685,23 @@
"node": ">=6"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@ -9179,6 +9751,16 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -9225,6 +9807,15 @@
"typescript": ">=4.8.4"
}
},
"node_modules/ts-fsrs": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/ts-fsrs/-/ts-fsrs-5.4.1.tgz",
"integrity": "sha512-mOp9+oexJexBTkwjg/jQI1aSUQRLIAvbimeKHLSmVdNJPwObugFNKmZkoggH5d6kZ0uaWLboP1Al1DnXAfIb9w==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@ -9646,6 +10237,236 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/vite": {
"version": "8.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
"integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.5",
"postcss": "^8.5.16",
"rolldown": "~1.1.4",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.3.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vite/node_modules/postcss": {
"version": "8.5.19",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
"integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vitest/node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -9750,6 +10571,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",

View file

@ -6,7 +6,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "vitest run"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@ -23,6 +24,7 @@
"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": {
@ -34,6 +36,7 @@
"eslint-config-next": "16.2.9",
"prisma": "^7.8.0",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.1.10"
}
}

View file

@ -0,0 +1,9 @@
-- 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");

View file

@ -0,0 +1,64 @@
-- 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");

View file

@ -0,0 +1,40 @@
-- 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");

View file

@ -17,6 +17,8 @@ model Class {
decks Deck[]
quizSets QuizSet[]
materialGroups MaterialGroup[]
spacedRepetitionSets SpacedRepetitionSet[]
arcadePacks ArcadePack[]
}
model Deck {
@ -33,6 +35,7 @@ model Deck {
cards Flashcard[]
shareLink ShareLink?
progress StudyProgress[]
spacedRepetitionMemberships SpacedRepetitionSetDeck[]
}
model Flashcard {
@ -43,6 +46,7 @@ model Flashcard {
sortOrder Int @default(0)
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
spacedRepetitionStates SpacedRepetitionCardState[]
}
model QuizSet {
@ -141,6 +145,54 @@ model Setting {
value String
}
model StudyActivity {
id String @id @default(uuid())
type String // "FLASHCARD" | "QUIZ_QUESTION" | "ARCADE_GROUP"
occurredAt DateTime @default(now())
@@index([occurredAt])
}
model ArcadePack {
id String @id @default(uuid())
classId String
gameType String
name String
description String?
schemaVersion Int
sourceJson String
normalizedJson String
validationReportJson String
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
attempts ArcadeAttempt[]
@@index([classId, gameType, sortOrder])
}
model ArcadeAttempt {
id String @id @default(uuid())
arcadePackId String
mode String
score Int
maxScore Int
accuracy Float
durationSeconds Int
mistakes Int
hintsUsed Int
settingsJson String
resultsJson String
seed String
completedAt DateTime @default(now())
arcadePack ArcadePack @relation(fields: [arcadePackId], references: [id], onDelete: Cascade)
@@index([arcadePackId, completedAt])
}
model MaterialGroup {
id String @id @default(uuid())
classId String
@ -154,3 +206,60 @@ 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])
}

View file

@ -0,0 +1,19 @@
import { notFound } from "next/navigation";
import { randomUUID } from "node:crypto";
import { ARCADE_RENDERERS } from "@/components/arcade/rendererRegistry";
import { getArcadePack } from "@/services/arcadeService";
export default async function ConnectionsPlayPage(
props: PageProps<"/[classSlug]/arcade/connections/[packId]/play">
) {
const [{ classSlug, packId }, query] = await Promise.all([props.params, props.searchParams]);
const pack = await getArcadePack(packId);
if (!pack || pack.class.slug !== classSlug || pack.gameType !== "connections" || pack.normalized.type !== "connections") notFound();
const normalized = pack.normalized;
const mistakesValue = Number(query.mistakes);
const allowedMistakes = Number.isInteger(mistakesValue) && mistakesValue >= 1 && mistakesValue <= 8
? mistakesValue
: normalized.settings.allowedMistakes;
const Renderer = ARCADE_RENDERERS.connections;
return <Renderer seed={randomUUID()} packId={pack.id} packName={pack.name} classSlug={classSlug} pack={normalized} settings={{ allowedMistakes, oneAwayFeedback: query.oneAway !== "0" }} />;
}

View file

@ -0,0 +1,12 @@
import { notFound } from "next/navigation";
import { ConnectionsHub } from "@/components/arcade/ConnectionsHub";
import { getClassBySlug } from "@/services/classService";
import { listArcadePacks } from "@/services/arcadeService";
export default async function ConnectionsHubPage(props: PageProps<"/[classSlug]/arcade/connections">) {
const { classSlug } = await props.params;
const classItem = await getClassBySlug(classSlug);
if (!classItem) notFound();
const packs = await listArcadePacks(classItem.id, "connections");
return <ConnectionsHub classId={classItem.id} classSlug={classSlug} initialPacks={packs} />;
}

View file

@ -0,0 +1,21 @@
import { randomUUID } from "node:crypto";
import { notFound } from "next/navigation";
import { ARCADE_RENDERERS } from "@/components/arcade/rendererRegistry";
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
import type { CrosswordSize, NormalizedCrosswordPack } from "@/types/arcade";
import { getArcadePack } from "@/services/arcadeService";
const SIZES = new Set<CrosswordSize>(["mini", "standard", "large", "extra-large"]);
export default async function CrosswordPlayPage(props: { params: Promise<{ classSlug: string; packId: string }>; searchParams: Promise<Record<string, string | string[] | undefined>> }) {
const [{ classSlug, packId }, query] = await Promise.all([props.params, props.searchParams]);
const pack = await getArcadePack(packId);
if (!pack || pack.class.slug !== classSlug || pack.gameType !== "crossword" || pack.normalized.type !== "crossword") notFound();
const requestedSize = typeof query.size === "string" ? query.size : "standard";
const size: CrosswordSize = SIZES.has(requestedSize as CrosswordSize) ? requestedSize as CrosswordSize : "standard";
const seed = randomUUID();
const normalized = pack.normalized as NormalizedCrosswordPack;
const layout = generateCrosswordLayout(normalized, size, seed);
const Renderer = ARCADE_RENDERERS.crossword;
return <Renderer seed={seed} packId={pack.id} packName={pack.name} classSlug={classSlug} pack={normalized} layout={layout} settings={{ size, instantCheck: query.instant === "1", allowHints: query.hints !== "0" }} />;
}

View file

@ -0,0 +1,12 @@
import { notFound } from "next/navigation";
import { CrosswordHub } from "@/components/arcade/CrosswordHub";
import { getClassBySlug } from "@/services/classService";
import { listArcadePacks } from "@/services/arcadeService";
export default async function CrosswordHubPage(props: { params: Promise<{ classSlug: string }> }) {
const { classSlug } = await props.params;
const classItem = await getClassBySlug(classSlug);
if (!classItem) notFound();
const packs = await listArcadePacks(classItem.id, "crossword");
return <CrosswordHub classId={classItem.id} classSlug={classSlug} initialPacks={packs} />;
}

View file

@ -0,0 +1,3 @@
export default function ArcadeLayout({ children }: { children: React.ReactNode }) {
return <div className="arcade-route">{children}</div>;
}

View file

@ -0,0 +1,46 @@
import Link from "next/link";
import { ARCADE_GAMES } from "@/config/arcadeGames";
function ConnectionsVisual() {
return <div className="arcade-visual connections-visual" aria-hidden><div className="connections-visual-grid">{Array.from({ length: 16 }, (_, index) => <i key={index} />)}</div><span className="connections-visual-label">4 hidden groups</span></div>;
}
function CrosswordVisual() {
const cells = ["C", "", "", "", "R", "", "O", "", "S", "T", "U", "D", "Y", "", "S", "", "", "", "W", "", "", "", "O", "", "", "", "R", "", "", "", "D", ""];
return <div className="arcade-visual crossword-visual" aria-hidden><div className="crossword-grid">{cells.map((letter, index) => <i key={index} className={letter ? "has-letter" : ""}>{letter}</i>)}</div><span className="crossword-pencil"></span></div>;
}
function FallingBlocksVisual() {
return <div className="arcade-visual blocks-visual" aria-hidden><span className="blocks-arrow"></span><div className="blocks-piece blocks-piece-a"><i /><i /><i /><i /></div><div className="blocks-piece blocks-piece-b"><i /><i /><i /></div><div className="blocks-floor">{Array.from({ length: 9 }, (_, index) => <i key={index} />)}</div></div>;
}
function AsteroidVisual() {
return <div className="arcade-visual asteroid-visual" aria-hidden><i className="arcade-star star-a" /><i className="arcade-star star-b" /><i className="arcade-star star-c" /><span className="asteroid asteroid-a" /><span className="asteroid asteroid-b" /><span className="asteroid asteroid-c" /><span className="space-station"><i /></span><span className="laser-beam" /><span className="shield-ring" /></div>;
}
function GameVisual({ gameKey }: { gameKey: (typeof ARCADE_GAMES)[number]["key"] }) {
if (gameKey === "connections") return <ConnectionsVisual />;
if (gameKey === "crossword") return <CrosswordVisual />;
if (gameKey === "falling-blocks") return <FallingBlocksVisual />;
return <AsteroidVisual />;
}
export default async function ArcadePage(props: PageProps<"/[classSlug]/arcade">) {
const { classSlug } = await props.params;
return (
<div className="arcade-lobby pb-20">
<div className="arcade-lobby-heading mb-8">
<p className="text-xs font-bold uppercase tracking-[0.22em] text-primary">Insert curiosity</p>
<h2 className="mt-2 text-4xl font-black tracking-[-0.05em] text-text-heading sm:text-5xl">Choose your cabinet</h2>
<p className="mt-3 max-w-2xl text-sm leading-6 text-text-secondary">Step away from the desk and turn your study material into a quick challenge.</p>
</div>
<div className="arcade-cabinet-grid grid gap-5 sm:grid-cols-2 xl:grid-cols-4">
{ARCADE_GAMES.map((game) => {
const card = <article className={`arcade-cabinet arcade-cabinet-${game.key} ${game.available ? "is-playable" : "is-coming"}`}><GameVisual gameKey={game.key} /><div className="arcade-cabinet-copy"><p className="arcade-cabinet-status">{game.estimatedMinutes}</p><h3>{game.name}</h3><p>{game.description}</p><span className="arcade-cabinet-action">{game.available ? `Play ${game.name}` : "Coming soon"}</span></div></article>;
return game.available ? <Link key={game.key} href={`/${classSlug}/arcade/${game.path}`} className="rounded-[1.75rem] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary">{card}</Link> : <div key={game.key}>{card}</div>;
})}
</div>
</div>
);
}

View file

@ -90,9 +90,9 @@ export default function DeckStudyPage() {
}
return (
<div className="max-w-4xl mx-auto w-full py-2 md:py-4">
<div className="mx-auto w-full max-w-5xl py-1 md:py-3">
{/* Header */}
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 mb-6">
<div className="mb-7 flex flex-col items-start justify-between gap-4 rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5 md:flex-row md:items-center">
<div>
<button
onClick={() => router.push(`/${classSlug}/flashcards`)}
@ -103,7 +103,8 @@ export default function DeckStudyPage() {
</svg>
Back to decks
</button>
<h1 className="text-xl font-bold text-text-heading">{deck.name}</h1>
<p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Flashcard session</p>
<h1 className="editorial-title mt-1 text-3xl text-text-heading">{deck.name}</h1>
{deck.description && (
<p className="text-sm text-text-secondary mt-1">{deck.description}</p>
)}
@ -126,7 +127,7 @@ export default function DeckStudyPage() {
<div className="flex items-center gap-2 md:gap-4 ml-auto">
<ShareMenu targetType="DECK" contentId={deck.id} classSlug={classSlug} />
<div className="flex bg-bg-surface-alt rounded-lg p-0.5 border border-border-light">
<div className="flex rounded-xl border border-border-light bg-bg-surface-alt p-1">
<button
onClick={() => setView("study")}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${

View file

@ -27,6 +27,7 @@ import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { ReorderItem } from "@/types/study";
interface DeckItem {
id: string;
@ -65,7 +66,14 @@ function getProgressLabel(deck: DeckItem) {
return `${current}/${total}, ${modeLabel} · ${correctCount}`;
}
function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
interface SortableDeckCardProps {
deck: DeckItem;
onEdit: (deck: DeckItem) => void;
onDelete: (deck: DeckItem) => void;
classSlug: string;
}
function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCardProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: deck.id });
const style = {
transform: CSS.Transform.toString(transform),
@ -76,8 +84,8 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
const progressLabel = getProgressLabel(deck);
return (
<div ref={setNodeRef} style={style} className="group flex flex-col bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300 h-full">
<div className="p-4 flex flex-col flex-1 h-full">
<div ref={setNodeRef} style={style} className="group flex h-full flex-col rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)] transition-all duration-300 hover:-translate-y-0.5 hover:border-primary/25 hover:shadow-[var(--shadow-card-hover)]">
<div className="flex h-full flex-1 flex-col p-5">
<div className="flex items-start gap-2">
<div {...attributes} {...listeners} className="mt-1 cursor-grab active:cursor-grabbing text-text-muted hover:text-text-heading">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -85,7 +93,7 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
</svg>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-text-heading mb-1">{deck.name}</h3>
<h3 className="mb-1 text-lg font-extrabold text-text-heading">{deck.name}</h3>
{deck.description && <p className="text-sm text-text-secondary mb-3 line-clamp-2">{deck.description}</p>}
</div>
</div>
@ -99,7 +107,8 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
)}
</div>
<div className="flex items-center gap-2 pt-4 border-t border-border-light pl-7">
<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">
{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">
@ -125,6 +134,8 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
Study
</Link>
)}
</div>
<div className="flex items-center justify-end gap-2">
<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" />
@ -135,6 +146,8 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
<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>
<ShareMenu targetType="DECK" contentId={deck.id} classSlug={classSlug} compact />
</div>
</div>
</div>
</div>
@ -309,8 +322,6 @@ 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;
@ -339,7 +350,7 @@ export default function FlashcardsPage() {
}
const affectedGroups = new Set([activeDeck.groupId, targetGroupId]);
const updates: any[] = [];
const updates: ReorderItem[] = [];
affectedGroups.forEach(gId => {
const gItems = newItems.filter(d => d.groupId === gId);
@ -369,15 +380,15 @@ export default function FlashcardsPage() {
const activeDeck = decks.find(d => d.id === activeId);
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<div className="pb-20">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-text-heading">Flashcard Decks</h2>
<div className="flex items-center gap-3">
<button onClick={() => setIsCreatingGroup(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-bg-surface-alt text-text-heading font-medium border border-border-light hover:bg-border transition-all duration-200 shadow-sm cursor-pointer">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Collection</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Flashcard decks</h2></div>
<div className="grid grid-cols-2 gap-3 sm:flex">
<button onClick={() => setIsCreatingGroup(true)} className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl border border-border-light bg-bg-surface px-4 font-bold text-text-heading shadow-sm transition-colors hover:bg-bg-surface-alt">
Add Group
</button>
<button onClick={() => setShowImport(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 shadow-sm cursor-pointer">
<button onClick={() => setShowImport(true)} className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl bg-primary px-4 font-bold text-white shadow-sm transition-colors hover:bg-primary-hover">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
@ -387,7 +398,7 @@ export default function FlashcardsPage() {
</div>
{isCreatingGroup && (
<div className="mb-6 p-4 bg-bg-surface rounded-xl border border-primary/20 shadow-sm flex items-center gap-3">
<div className="animate-slide-up mb-6 flex flex-col gap-3 rounded-2xl border border-primary/20 bg-bg-surface p-5 shadow-sm sm:flex-row sm:items-center">
<input
autoFocus
type="text"
@ -407,8 +418,8 @@ export default function FlashcardsPage() {
{editingId && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-bg-surface rounded-xl p-6 w-full max-w-md shadow-lg border border-border">
<h3 className="text-lg font-semibold text-text-heading mb-4">Edit Deck</h3>
<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 mb-4 text-2xl text-text-heading">Edit deck</h3>
<input
type="text"
value={editName}
@ -443,7 +454,7 @@ export default function FlashcardsPage() {
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
<div className="space-y-8">
{groupedDecks.map(group => (
<div key={group.id} className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
<div key={group.id} className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
<div className={`flex items-center justify-between ${collapsedGroups[group.id] ? "" : "mb-4"}`}>
{editingGroupId === group.id ? (
<div className="flex items-center gap-2">
@ -456,7 +467,7 @@ export default function FlashcardsPage() {
<button onClick={() => toggleGroup(group.id)} className="p-1 rounded text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer" title={collapsedGroups[group.id] ? "Expand" : "Collapse"}>
<svg className={`w-5 h-5 transition-transform duration-200 ${collapsedGroups[group.id] ? '-rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</button>
<h3 className="text-lg font-semibold text-text-heading">{group.name}</h3>
<h3 className="text-lg font-extrabold text-text-heading">{group.name}</h3>
</div>
)}
@ -484,7 +495,7 @@ export default function FlashcardsPage() {
))}
{/* Uncategorized */}
<div className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
<div className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
<div className={`flex items-center gap-2 ${collapsedGroups["uncategorized"] ? "" : "mb-4"}`}>
<button onClick={() => toggleGroup("uncategorized")} className="p-1 rounded text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer" title={collapsedGroups["uncategorized"] ? "Expand" : "Collapse"}>
<svg className={`w-5 h-5 transition-transform duration-200 ${collapsedGroups["uncategorized"] ? '-rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>

View file

@ -11,9 +11,9 @@ export default async function ClassLayout(props: LayoutProps<"/[classSlug]">) {
}
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Dynamic Header & Tabs */}
<ClassHeader classSlug={classSlug} className={classData.name} />
<div className="app-page">
{/* Dynamic class header */}
<ClassHeader className={classData.name} />
{/* Page content */}
{props.children}

View file

@ -124,9 +124,9 @@ export default function QuizStudyPage() {
}
return (
<div className="max-w-4xl mx-auto w-full py-2 md:py-4">
<div className="mx-auto w-full max-w-5xl py-1 md:py-3">
{/* Header */}
<div className="flex flex-col md:flex-row items-start justify-between gap-4 mb-6">
<div className="mb-7 flex flex-col items-start justify-between gap-4 rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5 md:flex-row">
<div>
<button
onClick={() => router.push(`/${classSlug}/quizzes`)}
@ -142,7 +142,7 @@ export default function QuizStudyPage() {
onClick={() => setShowTopics(!showTopics)}
className="flex items-center gap-2 group cursor-pointer text-left focus:outline-none mb-2"
>
<h1 className="text-3xl font-bold font-serif text-text-heading group-hover:text-primary transition-colors">
<h1 className="editorial-title text-3xl text-text-heading transition-colors group-hover:text-primary sm:text-4xl">
{quiz.name}
</h1>
<svg
@ -180,7 +180,7 @@ export default function QuizStudyPage() {
<ShareMenu targetType="QUIZ" contentId={quiz.id} classSlug={classSlug} />
{attempts.length > 0 && (
<div className="flex bg-bg-surface-alt rounded-lg p-0.5 border border-border-light">
<div className="flex rounded-xl border border-border-light bg-bg-surface-alt p-1">
<button
onClick={() => setView("history")}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${

View file

@ -27,6 +27,7 @@ import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { ReorderItem } from "@/types/study";
interface QuizItem {
id: string;
@ -35,6 +36,12 @@ interface QuizItem {
groupId: string | null;
sortOrder: number;
_count: { questions: number; attempts: number };
progress: Array<{
mode: string;
currentIndex: number;
orderJson: string;
answersJson: string | null;
}>;
}
interface MaterialGroup {
@ -45,7 +52,14 @@ interface MaterialGroup {
const cache: Record<string, { quizzes: QuizItem[]; groups: MaterialGroup[] }> = {};
function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) {
interface SortableQuizCardProps {
quiz: QuizItem;
onEdit: (quiz: QuizItem) => void;
onDelete: (quiz: QuizItem) => void;
classSlug: string;
}
function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCardProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: quiz.id });
const style = {
transform: CSS.Transform.toString(transform),
@ -54,8 +68,8 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) {
};
return (
<div ref={setNodeRef} style={style} className="group bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300">
<div className="p-4 flex flex-col flex-1 h-full">
<div ref={setNodeRef} style={style} className="group rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)] transition-all duration-300 hover:-translate-y-0.5 hover:border-primary/25 hover:shadow-[var(--shadow-card-hover)]">
<div className="flex h-full flex-1 flex-col p-5">
<div className="flex items-start gap-2">
<div {...attributes} {...listeners} className="mt-1 cursor-grab active:cursor-grabbing text-text-muted hover:text-text-heading">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -63,7 +77,7 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) {
</svg>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-text-heading mb-1">{quiz.name}</h3>
<h3 className="mb-1 text-lg font-extrabold text-text-heading">{quiz.name}</h3>
{quiz.description && <p className="text-sm text-text-secondary mb-3 line-clamp-2">{quiz.description}</p>}
</div>
</div>
@ -79,10 +93,36 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) {
)}
</div>
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-border-light pl-7">
<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;
await fetch("/api/progress", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: "QUIZ", contentId: quiz.id, mode: "SEQUENTIAL" }),
}).catch(() => {});
window.location.href = `/${classSlug}/quizzes/${quiz.id}`;
}}
className="flex-1 cursor-pointer rounded-lg border border-border bg-bg-surface-alt px-4 py-2 text-center text-sm font-medium text-text-heading shadow-sm transition-all duration-200 hover:bg-border"
title="Restart"
>
Restart
</button>
</>
) : (
<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">
<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" />
@ -93,6 +133,8 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) {
<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>
<ShareMenu targetType="QUIZ" contentId={quiz.id} classSlug={classSlug} compact />
</div>
</div>
</div>
</div>
@ -278,8 +320,6 @@ 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) {
@ -311,7 +351,7 @@ export default function QuizzesPage() {
// Re-calculate sortOrder for the affected groups to persist
const affectedGroups = new Set([activeQuiz.groupId, targetGroupId]);
const updates: any[] = [];
const updates: ReorderItem[] = [];
affectedGroups.forEach(gId => {
const gItems = newItems.filter(q => q.groupId === gId);
@ -343,15 +383,15 @@ export default function QuizzesPage() {
const activeQuiz = quizzes.find(q => q.id === activeId);
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<div className="pb-20">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-text-heading">Quizzes</h2>
<div className="flex items-center gap-3">
<button onClick={() => setIsCreatingGroup(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-bg-surface-alt text-text-heading font-medium border border-border-light hover:bg-border transition-all duration-200 shadow-sm cursor-pointer">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Practice</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Practice quizzes</h2></div>
<div className="grid grid-cols-2 gap-3 sm:flex">
<button onClick={() => setIsCreatingGroup(true)} className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl border border-border-light bg-bg-surface px-4 font-bold text-text-heading shadow-sm transition-colors hover:bg-bg-surface-alt">
Add Group
</button>
<button onClick={() => setShowImport(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 shadow-sm cursor-pointer">
<button onClick={() => setShowImport(true)} className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl bg-primary px-4 font-bold text-white shadow-sm transition-colors hover:bg-primary-hover">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
@ -361,7 +401,7 @@ export default function QuizzesPage() {
</div>
{isCreatingGroup && (
<div className="mb-6 p-4 bg-bg-surface rounded-xl border border-primary/20 shadow-sm flex items-center gap-3">
<div className="animate-slide-up mb-6 flex flex-col gap-3 rounded-2xl border border-primary/20 bg-bg-surface p-5 shadow-sm sm:flex-row sm:items-center">
<input
autoFocus
type="text"
@ -382,8 +422,8 @@ export default function QuizzesPage() {
{/* Editing quiz modal/inline - simplified as a modal-like overlay for simplicity when editing */}
{editingId && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-bg-surface rounded-xl p-6 w-full max-w-md shadow-lg border border-border">
<h3 className="text-lg font-semibold text-text-heading mb-4">Edit Quiz</h3>
<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 mb-4 text-2xl text-text-heading">Edit quiz</h3>
<input
type="text"
value={editName}
@ -418,7 +458,7 @@ export default function QuizzesPage() {
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
<div className="space-y-8">
{groupedQuizzes.map(group => (
<div key={group.id} className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
<div key={group.id} className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
<div className={`flex items-center justify-between ${collapsedGroups[group.id] ? "" : "mb-4"}`}>
{editingGroupId === group.id ? (
<div className="flex items-center gap-2">
@ -431,7 +471,7 @@ export default function QuizzesPage() {
<button onClick={() => toggleGroup(group.id)} className="p-1 rounded text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer" title={collapsedGroups[group.id] ? "Expand" : "Collapse"}>
<svg className={`w-5 h-5 transition-transform duration-200 ${collapsedGroups[group.id] ? '-rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</button>
<h3 className="text-lg font-semibold text-text-heading">{group.name}</h3>
<h3 className="text-lg font-extrabold text-text-heading">{group.name}</h3>
</div>
)}
@ -459,7 +499,7 @@ export default function QuizzesPage() {
))}
{/* Uncategorized */}
<div className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
<div className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
<div className={`flex items-center gap-2 ${collapsedGroups["uncategorized"] ? "" : "mb-4"}`}>
<button onClick={() => toggleGroup("uncategorized")} className="p-1 rounded text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer" title={collapsedGroups["uncategorized"] ? "Expand" : "Collapse"}>
<svg className={`w-5 h-5 transition-transform duration-200 ${collapsedGroups["uncategorized"] ? '-rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>

View file

@ -0,0 +1,5 @@
import { SpacedRepetitionViewer } from "@/components/spaced-repetition/SpacedRepetitionViewer";
export default function SpacedRepetitionStudyPage() {
return <SpacedRepetitionViewer />;
}

View file

@ -0,0 +1,5 @@
import { SpacedRepetitionSets } from "@/components/spaced-repetition/SpacedRepetitionSets";
export default function SpacedRepetitionPage() {
return <SpacedRepetitionSets />;
}

View file

@ -13,9 +13,9 @@ export default async function ProtectedLayout({
}
return (
<div className="min-h-screen flex flex-col">
<div className="min-h-screen">
<Navbar />
<main className="flex-1">{children}</main>
<main className="min-h-screen md:ml-60">{children}</main>
</div>
);
}

View file

@ -1,15 +1,22 @@
"use client";
import { useState, useEffect } from "react";
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"];
export default function HomePage() {
const [classes, setClasses] = useState<ClassItem[]>([]);
const [loading, setLoading] = useState(true);
@ -20,256 +27,112 @@ export default function HomePage() {
const [editName, setEditName] = useState("");
const [savingEdit, setSavingEdit] = useState(false);
useEffect(() => {
fetchClasses();
}, []);
useEffect(() => { fetchClasses(); }, []);
async function fetchClasses() {
try {
const res = await fetch("/api/classes");
const data = await res.json();
setClasses(data);
} finally {
setLoading(false);
}
setClasses(await res.json());
} finally { setLoading(false); }
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!newName.trim()) return;
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) {
setNewName("");
setShowCreate(false);
fetchClasses();
}
} finally {
setCreating(false);
}
const res = await fetch("/api/classes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newName.trim() }) });
if (res.ok) { setNewName(""); setShowCreate(false); fetchClasses(); }
} finally { setCreating(false); }
}
async function handleDelete(id: string, name: string) {
if (!confirm(`Delete "${name}" and all its decks and quizzes?`)) return;
await fetch(`/api/classes/${id}`, { method: "DELETE" });
fetchClasses();
}
async function handleSaveEdit(id: string) {
if (!editName.trim()) {
setEditingId(null);
return;
}
if (!editName.trim()) { setEditingId(null); return; }
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) {
setEditingId(null);
fetchClasses();
}
} finally {
setSavingEdit(false);
}
const res = await fetch(`/api/classes/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: editName.trim() }) });
if (res.ok) { setEditingId(null); fetchClasses(); }
} finally { setSavingEdit(false); }
}
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-text-heading">Your Classes</h1>
<p className="text-text-secondary mt-1">
Select a class to study flashcards or take quizzes
</p>
</div>
<button
onClick={() => setShowCreate(!showCreate)}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 shadow-sm 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="M12 4v16m8-8H4" />
</svg>
Add Class
</button>
</div>
<div className="app-page">
<ActivityBanner onNewClass={() => setShowCreate(true)} />
{/* Create class inline form */}
{showCreate && (
<div className="bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] p-5 mb-6 animate-slide-up">
<form onSubmit={handleCreate} className="flex gap-3">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="e.g. PHA 109, Anatomy, Nursing Fundamentals..."
autoFocus
className="flex-1 px-3.5 py-2.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200"
/>
<button
type="submit"
disabled={creating || !newName.trim()}
className="px-5 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover disabled:opacity-50 transition-all duration-200 cursor-pointer"
>
{creating ? "Creating..." : "Create"}
</button>
<button
type="button"
onClick={() => {
setShowCreate(false);
setNewName("");
}}
className="px-4 py-2.5 rounded-lg border border-border text-text-secondary hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
>
Cancel
</button>
<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">
<div className="mb-4">
<h2 className="editorial-title text-2xl text-text-heading">Create a class</h2>
<p className="mt-1 text-sm text-text-secondary">Give this study space a short, memorable name.</p>
</div>
<form onSubmit={handleCreate} className="flex flex-col gap-3 sm:flex-row">
<input value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="e.g. Pathophysiology" autoFocus className="min-h-12 flex-1 rounded-xl border border-border bg-bg-surface-alt/60 px-4 text-text-heading placeholder:text-text-muted focus:border-primary" />
<button type="submit" disabled={creating || !newName.trim()} className="min-h-12 rounded-xl bg-primary px-6 text-sm font-bold text-white hover:bg-primary-hover disabled:opacity-45">{creating ? "Creating…" : "Create class"}</button>
<button type="button" onClick={() => { setShowCreate(false); setNewName(""); }} className="min-h-12 rounded-xl border border-border px-5 text-sm font-bold text-text-secondary hover:bg-bg-surface-alt">Cancel</button>
</form>
</div>
</section>
)}
{/* Loading state */}
<div className="mb-5 flex items-end justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-[0.18em] text-primary">Study spaces</p>
<h2 className="editorial-title mt-1 text-3xl text-text-heading">Your classes</h2>
</div>
<p className="hidden text-sm text-text-muted sm:block">Flashcards and quizzes, organized by class.</p>
</div>
{loading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div
key={i}
className="bg-bg-surface rounded-xl border border-border-light p-6 animate-subtle-pulse"
>
<div className="h-5 bg-bg-surface-alt rounded w-2/3 mb-3" />
<div className="h-4 bg-bg-surface-alt rounded w-1/3" />
</div>
))}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{[1, 2, 3].map((i) => <div key={i} className="h-52 animate-subtle-pulse rounded-2xl border border-border-light bg-bg-surface" />)}
</div>
)}
{/* Empty state */}
{!loading && classes.length === 0 && (
<div className="text-center py-16">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-bg-callout mb-4">
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h2 className="text-lg font-semibold text-text-heading mb-1">
No classes yet
</h2>
<p className="text-text-secondary mb-4">
Create your first class to start studying
</p>
<button
onClick={() => setShowCreate(true)}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 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="M12 4v16m8-8H4" />
</svg>
Add Your First Class
</button>
<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>
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-text-secondary">Create your first class, then add flashcards or practice quizzes.</p>
<button onClick={() => setShowCreate(true)} className="mt-6 min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white hover:bg-primary-hover">Create your first class</button>
</div>
)}
{/* Class grid */}
{!loading && classes.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{classes.map((cls) => (
<div
key={cls.id}
className="group relative bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300"
>
<Link
href={`/${cls.slug}`}
className="block p-6"
>
<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)]">
<div className="h-1.5" style={{ backgroundColor: accents[index % accents.length] }} />
<Link href={`/${cls.slug}`} className="block min-h-48 p-5 sm:p-6">
<div className="mb-8 flex items-start justify-between gap-4">
<div className="grid h-11 w-11 place-items-center rounded-xl bg-bg-surface-alt text-sm font-extrabold text-text-heading">{cls.name.slice(0, 2).toUpperCase()}</div>
<span className="text-xl text-text-muted transition-transform group-hover:translate-x-1" aria-hidden></span>
</div>
{editingId === cls.id ? (
<input
type="text"
value={editName}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
handleSaveEdit(cls.id);
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setEditingId(null);
}
}}
onBlur={() => handleSaveEdit(cls.id)}
autoFocus
disabled={savingEdit}
className="w-[calc(100%-2.5rem)] text-lg font-semibold text-text-heading px-2 py-1 -ml-2 rounded bg-bg-surface-alt border border-primary/30 focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
/>
) : (
<h3 className="text-lg font-semibold text-text-heading group-hover:text-primary transition-colors">
{cls.name}
</h3>
<input value={editName} onClick={(e) => { e.preventDefault(); e.stopPropagation(); }} onChange={(e) => setEditName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleSaveEdit(cls.id); } else if (e.key === "Escape") setEditingId(null); }} onBlur={() => handleSaveEdit(cls.id)} autoFocus disabled={savingEdit} className="w-full rounded-lg border border-primary bg-bg-surface-alt px-2 py-1 text-lg font-bold text-text-heading" />
) : <h3 className="text-xl font-extrabold text-text-heading group-hover:text-primary">{cls.name}</h3>}
<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 className="flex gap-4 mt-3">
<span className="inline-flex items-center gap-1.5 text-sm text-text-secondary">
<svg className="w-4 h-4 text-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
{cls._count.decks} {cls._count.decks === 1 ? "deck" : "decks"}
</span>
<span className="inline-flex items-center gap-1.5 text-sm text-text-secondary">
<svg className="w-4 h-4 text-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{cls._count.quizSets} {cls._count.quizSets === 1 ? "quiz" : "quizzes"}
</span>
</div>
</Link>
{/* Actions */}
<div className="absolute top-3 right-3 flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-all duration-200">
{/* Delete button */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDelete(cls.id, cls.name);
}}
className="p-1.5 rounded-lg text-text-muted hover:text-error hover:bg-error-bg transition-colors cursor-pointer"
title="Delete class"
>
<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>
{/* Edit button */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setEditingId(cls.id);
setEditName(cls.name);
}}
className="p-1.5 rounded-lg text-text-muted hover:text-primary hover:bg-primary/10 transition-colors cursor-pointer"
title="Rename class"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
</div>
<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">
<button onClick={() => { setEditingId(cls.id); setEditName(cls.name); }} className="grid h-9 w-9 place-items-center rounded-lg text-text-muted hover:bg-bg-callout hover:text-primary" title="Rename class" aria-label={`Rename ${cls.name}`}></button>
<button onClick={() => handleDelete(cls.id, cls.name)} className="grid h-9 w-9 place-items-center rounded-lg text-text-muted hover:bg-error-bg hover:text-error" title="Delete class" aria-label={`Delete ${cls.name}`}>×</button>
</div>
</article>
))}
</div>
)}

View file

@ -0,0 +1,17 @@
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 });
}

View file

@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
import { arcadePreviewRequestSchema } from "@/lib/validation/arcadeSchemas";
import * as arcadeService from "@/services/arcadeService";
export async function POST(request: NextRequest) {
const parsed = arcadePreviewRequestSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "A game type and raw JSON are required." }, { status: 400 });
}
try {
return NextResponse.json(arcadeService.previewArcadeImport(parsed.data.gameType, parsed.data.rawJson));
} catch (error) {
if (error instanceof ArcadeImportError) {
return NextResponse.json({ error: error.message, details: error.details }, { status: 400 });
}
throw error;
}
}

View file

@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { arcadeAttemptCreateSchema, crosswordAttemptCreateSchema } from "@/lib/validation/arcadeSchemas";
import * as arcadeService from "@/services/arcadeService";
export async function GET(
_request: NextRequest,
context: RouteContext<"/api/arcade/packs/[id]/attempts">
) {
const { id } = await context.params;
const pack = await arcadeService.getArcadePack(id);
if (!pack) return NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
return NextResponse.json(await arcadeService.listArcadeAttempts(id));
}
export async function POST(
request: NextRequest,
context: RouteContext<"/api/arcade/packs/[id]/attempts">
) {
const { id } = await context.params;
const pack = await arcadeService.getArcadePack(id);
if (!pack) return NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
const body = await request.json().catch(() => null);
const parsed = pack.gameType === "crossword"
? crosswordAttemptCreateSchema.safeParse(body)
: arcadeAttemptCreateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid completed attempt", details: parsed.error.issues }, { status: 400 });
}
try {
const attempt = pack.gameType === "crossword"
? await arcadeService.createCrosswordAttempt(id, parsed.data as Parameters<typeof arcadeService.createCrosswordAttempt>[1])
: await arcadeService.createArcadeAttempt(id, parsed.data as Parameters<typeof arcadeService.createArcadeAttempt>[1]);
return attempt
? NextResponse.json(attempt, { status: 201 })
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Attempt could not be saved" },
{ status: 400 }
);
}
}

View file

@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
import type { CrosswordSize } from "@/types/arcade";
import { getArcadePack } from "@/services/arcadeService";
const CROSSWORD_SIZES = new Set<CrosswordSize>(["mini", "standard", "large", "extra-large"]);
export async function GET(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const { id } = await context.params;
const sizeValue = request.nextUrl.searchParams.get("size") ?? "standard";
if (!CROSSWORD_SIZES.has(sizeValue as CrosswordSize)) {
return NextResponse.json({ error: "A valid Crossword size is required." }, { status: 400 });
}
const pack = await getArcadePack(id);
if (!pack || pack.gameType !== "crossword" || pack.normalized.type !== "crossword") {
return NextResponse.json({ error: "Crossword pack not found" }, { status: 404 });
}
const size = sizeValue as CrosswordSize;
return NextResponse.json(generateCrosswordLayout(pack.normalized, size, `crossword-hub-preview-v1:${id}:${size}`));
}

View file

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { arcadePackUpdateSchema } from "@/lib/validation/arcadeSchemas";
import * as arcadeService from "@/services/arcadeService";
export async function GET(
_request: NextRequest,
context: RouteContext<"/api/arcade/packs/[id]">
) {
const { id } = await context.params;
const pack = await arcadeService.getArcadePack(id);
return pack
? NextResponse.json(pack)
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
}
export async function PATCH(
request: NextRequest,
context: RouteContext<"/api/arcade/packs/[id]">
) {
const { id } = await context.params;
const parsed = arcadePackUpdateSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "A valid name is required" }, { status: 400 });
const pack = await arcadeService.updateArcadePack(id, parsed.data.name);
return pack
? NextResponse.json(pack)
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
}
export async function DELETE(
_request: NextRequest,
context: RouteContext<"/api/arcade/packs/[id]">
) {
const { id } = await context.params;
const deleted = await arcadeService.deleteArcadePack(id);
return deleted
? NextResponse.json({ success: true })
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
}

View file

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
import { arcadePackCreateSchema } from "@/lib/validation/arcadeSchemas";
import * as arcadeService from "@/services/arcadeService";
export async function GET(request: NextRequest) {
const params = new URL(request.url).searchParams;
const classId = params.get("classId");
const gameType = params.get("gameType");
if (!classId || (gameType !== "connections" && gameType !== "crossword")) {
return NextResponse.json({ error: "Valid classId and gameType values are required." }, { status: 400 });
}
return NextResponse.json(await arcadeService.listArcadePacks(classId, gameType));
}
export async function POST(request: NextRequest) {
const parsed = arcadePackCreateSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "Invalid Arcade pack request", details: parsed.error.issues }, { status: 400 });
}
try {
const packs = await arcadeService.createArcadePacks(parsed.data);
return NextResponse.json({ packs, count: packs.length }, { status: 201 });
} catch (error) {
if (error instanceof ArcadeImportError) {
return NextResponse.json({ error: error.message, details: error.details }, { status: 400 });
}
if (error instanceof Error) {
const status = error.message === "Class not found" ? 404 : 400;
return NextResponse.json({ error: error.message }, { status });
}
throw error;
}
}

View file

@ -1,116 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { createSession } from "@/lib/auth";
import { loginSchema } from "@/lib/validation/authSchemas";
import { checkRateLimit } from "@/lib/rateLimiter";
// 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;
}
import { login } from "@/services/authService";
export async function POST(request: NextRequest) {
// 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) {
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
if (!checkRateLimit(`login:${ip}`).allowed) {
return NextResponse.json(
{ error: "Too many requests. Try again shortly." },
{ status: 429 }
);
}
const body = await request.json().catch(() => null);
if (!body?.password || typeof body.password !== "string") {
const parsed = loginSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: "Password is required" },
{ error: parsed.error.issues[0]?.message ?? "Invalid request" },
{ status: 400 }
);
}
// Check lockout state
let security = await prisma.authSecurity.findUnique({ where: { id: 1 } });
if (!security) {
security = await prisma.authSecurity.create({ data: { id: 1 } });
const result = await login(parsed.data.password);
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: result.status });
}
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 });
}

View file

@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { completePasswordResetSchema } from "@/lib/validation/authSchemas";
import { completePasswordReset } from "@/services/authService";
export async function POST(request: NextRequest) {
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 });
}

View file

@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { checkRateLimit } from "@/lib/rateLimiter";
import { requestPasswordReset } from "@/services/authService";
export async function POST(request: NextRequest) {
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const rateLimit = checkRateLimit(`password-reset-request:${ip}`, {
windowMs: 60_000,
maxRequests: 1,
});
if (!rateLimit.allowed) {
return NextResponse.json(
{ error: "A reset token was requested recently. Try again shortly." },
{ status: 429 }
);
}
if (!(await requestPasswordReset())) {
return NextResponse.json(
{ error: "Password recovery is unavailable before initial setup." },
{ status: 409 }
);
}
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,25 @@
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) {
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
if (!checkRateLimit(`password-reset-verify:${ip}`).allowed) {
return NextResponse.json(
{ error: "Too many attempts. Try again shortly." },
{ status: 429 }
);
}
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 });
}

View file

@ -1,19 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { reorderRequestSchema } from "@/lib/validation/reorderSchemas";
export async function PATCH(request: NextRequest) {
try {
const body = await request.json();
const { items } = body;
if (!Array.isArray(items)) {
const parsed = reorderRequestSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const { items } = parsed.data;
// items should be an array of { id, sortOrder, groupId }
// Using a transaction to perform all updates
await prisma.$transaction(
items.map((item: any) =>
items.map((item) =>
prisma.deck.update({
where: { id: item.id },
data: {

View file

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import type { Prisma } from "@/generated/prisma/client";
export async function PATCH(
request: NextRequest,
@ -10,7 +11,7 @@ export async function PATCH(
const body = await request.json();
const { name, sortOrder } = body;
const updateData: any = {};
const updateData: Prisma.MaterialGroupUpdateInput = {};
if (name !== undefined) updateData.name = name;
if (sortOrder !== undefined) updateData.sortOrder = sortOrder;

View file

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import type { Prisma } from "@/generated/prisma/client";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
@ -14,7 +15,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Invalid type" }, { status: 400 });
}
const whereClause: any = { classId };
const whereClause: Prisma.MaterialGroupWhereInput = { classId };
if (type) {
whereClause.type = type;
}

View file

@ -1,19 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { reorderRequestSchema } from "@/lib/validation/reorderSchemas";
export async function PATCH(request: NextRequest) {
try {
const body = await request.json();
const { items } = body;
if (!Array.isArray(items)) {
const parsed = reorderRequestSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const { items } = parsed.data;
// items should be an array of { id, sortOrder, groupId }
// Using a transaction to perform all updates
await prisma.$transaction(
items.map((item: any) =>
items.map((item) =>
prisma.quizSet.update({
where: { id: item.id },
data: {

View file

@ -1,16 +1,21 @@
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 === "connections" || type === "crossword" ? type : null;
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
const type = getInstructionType(request);
if (!type) return NextResponse.json({ error: "Invalid instruction type" }, { status: 400 });
const instructions = await settingsService.getLlmInstructions(type);
return NextResponse.json({ value: instructions });
}
export async function PATCH(request: NextRequest) {
const { searchParams } = new URL(request.url);
const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
const type = getInstructionType(request);
if (!type) return NextResponse.json({ error: "Invalid instruction type" }, { status: 400 });
const body = await request.json().catch(() => null);
if (!body || typeof body.value !== "string") {

View file

@ -0,0 +1,16 @@
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);
}
}

View file

@ -0,0 +1,43 @@
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);
}
}

View file

@ -0,0 +1,22 @@
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);
}
}

View file

@ -0,0 +1,45 @@
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);
}
}

View file

@ -0,0 +1,15 @@
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);
}
}

View file

@ -0,0 +1,30 @@
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);
}
}

View file

@ -2,64 +2,104 @@
@layer base {
:root {
--theme-bg-base: #f0f4f2;
--theme-bg-surface: #ffffff;
--theme-bg-surface-alt: #f4f7f5;
--theme-bg-callout: #e8f4f0;
--theme-primary: #2a7d7d;
--theme-primary-hover: #1f6363;
--theme-primary-light: #3a9e9e;
--theme-text-heading: #1a2f2f;
--theme-text-body: #2d4a4a;
--theme-text-secondary: #4a6a6a;
--theme-text-muted: #7a9a9a;
--theme-border: #c8d8d0;
--theme-border-light: #dce8e2;
--theme-success: #2d8a4e;
--theme-success-bg: #e8f5ed;
--theme-error: #c44c4c;
--theme-error-bg: #fce8e8;
--theme-badge-bg: #e0eeea;
--theme-badge-text: #2a6a5a;
--theme-danger: #dc3545;
--theme-danger-hover: #c82333;
--theme-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--theme-shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
--theme-shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.15), 0 8px 20px rgba(0, 0, 0, 0.1);
--theme-bg-base: #f5f2ea;
--theme-bg-surface: #fffdf8;
--theme-bg-surface-alt: #eeeae0;
--theme-bg-callout: #ebe9ff;
--theme-primary: #4f46e5;
--theme-primary-hover: #3f37c9;
--theme-primary-light: #756ef0;
--theme-accent: #f97360;
--theme-text-heading: #172033;
--theme-text-body: #344054;
--theme-text-secondary: #667085;
--theme-text-muted: #8b91a0;
--theme-border: #d7d2c7;
--theme-border-light: #e7e2d8;
--theme-success: #25845f;
--theme-success-bg: #e3f4ec;
--theme-error: #c44747;
--theme-error-bg: #fae8e5;
--theme-badge-bg: #e8e5ff;
--theme-badge-text: #4840bb;
--theme-danger: #c44747;
--theme-danger-hover: #a93636;
--theme-shadow-card: 0 1px 2px rgba(23, 32, 51, 0.04), 0 8px 24px rgba(23, 32, 51, 0.055);
--theme-shadow-card-hover: 0 2px 4px rgba(23, 32, 51, 0.05), 0 18px 40px rgba(23, 32, 51, 0.1);
--theme-shadow-modal: 0 28px 80px rgba(23, 32, 51, 0.22);
}
:root.dark {
--theme-bg-base: #121212;
--theme-bg-surface: #1e1e1e;
--theme-bg-surface-alt: #2d2d2d;
--theme-bg-callout: #1a2723;
--theme-primary: #4ab2b2;
--theme-primary-hover: #5ec7c7;
--theme-primary-light: #6cd1d1;
--theme-text-heading: #f9fafb;
--theme-text-body: #e5e7eb;
--theme-text-secondary: #9ca3af;
--theme-text-muted: #6b7280;
--theme-border: #374151;
--theme-border-light: #2d3748;
--theme-success: #4ade80;
--theme-success-bg: #064e3b;
--theme-error: #f87171;
--theme-error-bg: #7f1d1d;
--theme-badge-bg: #1f2937;
--theme-badge-text: #5ec7c7;
--theme-danger: #ef4444;
--theme-danger-hover: #f87171;
--theme-bg-base: #0d1117;
--theme-bg-surface: #151b23;
--theme-bg-surface-alt: #1c2430;
--theme-bg-callout: #25234a;
--theme-primary: #8b83ff;
--theme-primary-hover: #a49eff;
--theme-primary-light: #b2adff;
--theme-accent: #ff8a75;
--theme-text-heading: #f6f3ec;
--theme-text-body: #d8dde6;
--theme-text-secondary: #aab3c0;
--theme-text-muted: #7f8998;
--theme-border: #354050;
--theme-border-light: #293342;
--theme-success: #58c596;
--theme-success-bg: #173c30;
--theme-error: #ff8177;
--theme-error-bg: #472424;
--theme-badge-bg: #2d2959;
--theme-badge-text: #bbb7ff;
--theme-danger: #ff8177;
--theme-danger-hover: #ffa098;
--theme-shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3), 0 10px 30px rgba(0, 0, 0, 0.18);
--theme-shadow-card-hover: 0 2px 4px rgba(0, 0, 0, 0.36), 0 20px 44px rgba(0, 0, 0, 0.28);
--theme-shadow-modal: 0 30px 90px rgba(0, 0, 0, 0.55);
}
--theme-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.2);
--theme-shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.6), 0 2px 4px rgba(0, 0, 0, 0.4);
--theme-shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.5), 0 8px 20px rgba(0, 0, 0, 0.3);
* {
border-color: var(--theme-border-light);
}
html {
background: var(--theme-bg-base);
scroll-behavior: smooth;
}
body {
min-width: 320px;
background:
radial-gradient(circle at 88% -10%, color-mix(in srgb, var(--theme-primary) 9%, transparent), transparent 32rem),
var(--theme-bg-base);
color: var(--theme-text-body);
font-family: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
button, a, input, textarea, select {
outline-color: var(--theme-primary);
}
:focus-visible {
outline: 3px solid color-mix(in srgb, var(--theme-primary) 45%, transparent);
outline-offset: 3px;
}
button, [role="button"], a {
-webkit-tap-highlight-color: transparent;
}
button:not(:disabled), [role="button"], a {
cursor: pointer;
}
::selection {
background: color-mix(in srgb, var(--theme-primary) 24%, transparent);
}
}
@theme inline {
/* ── Sage / Teal palette mapped from CSS variables ── */
--color-bg-base: var(--theme-bg-base);
--color-bg-surface: var(--theme-bg-surface);
--color-bg-surface-alt: var(--theme-bg-surface-alt);
@ -67,6 +107,7 @@
--color-primary: var(--theme-primary);
--color-primary-hover: var(--theme-primary-hover);
--color-primary-light: var(--theme-primary-light);
--color-accent: var(--theme-accent);
--color-text-heading: var(--theme-text-heading);
--color-text-body: var(--theme-text-body);
--color-text-secondary: var(--theme-text-secondary);
@ -81,123 +122,603 @@
--color-badge-text: var(--theme-badge-text);
--color-danger: var(--theme-danger);
--color-danger-hover: var(--theme-danger-hover);
/* Typography */
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
/* Shadows */
--font-sans: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
--font-serif: var(--font-newsreader), ui-serif, Georgia, serif;
--shadow-card: var(--theme-shadow-card);
--shadow-card-hover: var(--theme-shadow-card-hover);
--shadow-modal: var(--theme-shadow-modal);
/* Animations */
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-spring: cubic-bezier(0.22, 1, 0.36, 1);
}
/* ── Base Styles ── */
body {
background: var(--color-bg-base);
color: var(--color-text-body);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--color-border); border: 2px solid transparent; border-radius: 999px; background-clip: padding-box; }
.app-page {
width: 100%;
max-width: 1280px;
margin-inline: auto;
padding: 2rem clamp(1rem, 3vw, 3rem) 5rem;
}
/* ── Scrollbar Styling ── */
::-webkit-scrollbar {
width: 6px;
.editorial-title {
font-family: var(--font-newsreader), Georgia, serif;
font-weight: 600;
letter-spacing: -0.035em;
}
::-webkit-scrollbar-track {
.paper-grid {
background-image: radial-gradient(color-mix(in srgb, var(--theme-text-muted) 18%, transparent) 0.7px, transparent 0.7px);
background-size: 18px 18px;
}
.perspective-1000 { perspective: 1000px; }
.preserve-3d { transform-style: preserve-3d; }
.backface-hidden { backface-visibility: hidden; }
.rotate-y-180 { transform: rotateY(180deg); }
.rotate-x-180 { transform: rotateX(180deg); }
@keyframes swipeLeft { to { transform: translateX(-44px) rotate(-6deg); opacity: 0; } }
@keyframes swipeRight { to { transform: translateX(44px) rotate(6deg); opacity: 0; } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(14px) scale(.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes subtlePulse { 0%, 100% { opacity: 1; } 50% { opacity: .58; } }
.animate-swipe-left { animation: swipeLeft .36s var(--ease-spring) forwards; }
.animate-swipe-right { animation: swipeRight .36s var(--ease-spring) forwards; }
.animate-fade-in { animation: fadeIn .2s ease-out; }
.animate-slide-up { animation: slideUp .32s var(--ease-spring); }
.animate-subtle-pulse { animation: subtlePulse 1.7s ease-in-out infinite; }
/* Arcade routes take over the complete application shell, including navigation. */
body:has(.arcade-route) {
--theme-bg-base: #070b18;
--theme-bg-surface: #11182a;
--theme-bg-surface-alt: #19233a;
--theme-bg-callout: #282357;
--theme-primary: #9a8cff;
--theme-primary-hover: #b5aaff;
--theme-text-heading: #f9f7ff;
--theme-text-body: #dbe2f4;
--theme-text-secondary: #aab6d1;
--theme-text-muted: #71809e;
--theme-border: #34415c;
--theme-border-light: #242f46;
min-height: 100vh;
background:
radial-gradient(circle at 76% 4%, rgba(116, 91, 255, .2), transparent 28rem),
radial-gradient(circle at 30% 92%, rgba(5, 198, 181, .11), transparent 34rem),
linear-gradient(145deg, #080c19, #0c1221 58%, #0b1020);
}
body:has(.arcade-route)::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
opacity: .11;
background-image: radial-gradient(rgba(201, 211, 255, .7) .7px, transparent .7px);
background-size: 22px 22px;
}
body:has(.arcade-route) main { min-height: 100vh; }
body:has(.arcade-route) .app-page { max-width: none; min-height: 100vh; }
body:has(.arcade-route) .arcade-route { width: min(100%, 1360px); margin-inline: auto; }
body:has(.arcade-route) .app-sidebar,
body:has(.arcade-route) .app-mobile-header {
border-color: rgba(138, 157, 201, .15);
background: rgba(8, 13, 27, .9);
box-shadow: 18px 0 55px rgba(0, 0, 0, .16);
}
body:has(.connections-world) {
background:
radial-gradient(circle at 28% 0%, rgba(88, 101, 242, .34), transparent 36rem),
radial-gradient(circle at 96% 28%, rgba(239, 71, 111, .16), transparent 30rem),
linear-gradient(145deg, #0b1329, #0e1932 54%, #142440);
}
.arcade-lobby-heading { position: relative; }
.arcade-lobby-heading::after {
content: "SELECT GAME";
position: absolute;
right: 0;
top: .5rem;
color: rgba(154, 140, 255, .08);
font-size: clamp(3rem, 8vw, 7rem);
font-weight: 950;
letter-spacing: -.06em;
line-height: .8;
pointer-events: none;
}
.arcade-cabinet {
position: relative;
display: flex;
min-height: 30rem;
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(148, 163, 202, .2);
border-radius: 1.75rem;
background: #11182a;
box-shadow: inset 0 1px rgba(255,255,255,.06), 0 22px 55px rgba(0,0,0,.28);
transition: transform .24s var(--ease-spring), box-shadow .24s ease, border-color .24s ease;
}
.arcade-cabinet.is-playable:hover { transform: translateY(-7px) rotate(-.3deg); border-color: rgba(181,170,255,.65); box-shadow: 0 30px 70px rgba(0,0,0,.38), 0 0 45px rgba(126,103,255,.13); }
.arcade-cabinet.is-coming { filter: saturate(.82); }
.arcade-visual { position: relative; height: 13.5rem; overflow: hidden; border-bottom: 1px solid rgba(255,255,255,.1); }
.arcade-cabinet-copy { display: flex; flex: 1; flex-direction: column; padding: 1.25rem; }
.arcade-cabinet-status { font-size: .65rem; font-weight: 900; letter-spacing: .18em; text-transform: uppercase; }
.arcade-cabinet-copy h3 { margin-top: .35rem; color: white; font-size: 1.55rem; font-weight: 900; letter-spacing: -.04em; }
.arcade-cabinet-copy > p:not(.arcade-cabinet-status) { margin-top: .55rem; flex: 1; color: #aab6d1; font-size: .86rem; line-height: 1.65; }
.arcade-cabinet-action { display: grid; min-height: 2.9rem; margin-top: 1.1rem; place-items: center; border: 1px solid rgba(255,255,255,.13); border-radius: .9rem; color: #8d9ab5; background: rgba(5,9,20,.3); font-size: .8rem; font-weight: 850; }
.is-playable .arcade-cabinet-action { color: #14152a; border-color: transparent; background: linear-gradient(135deg, #a89dff, #8170f5); box-shadow: 0 10px 28px rgba(126,103,255,.28); }
.connections-visual { display: grid; place-items: center; background: radial-gradient(circle at 50% 20%, rgba(180,162,255,.35), transparent 55%), linear-gradient(145deg,#342c64,#1d2042); }
.connections-visual-grid { display: grid; width: 9.5rem; grid-template-columns: repeat(4, 1fr); gap: .38rem; transform: perspective(400px) rotateX(8deg) rotateZ(-2deg); }
.connections-visual-grid i { aspect-ratio: 1; border-radius: .5rem; box-shadow: inset 0 1px rgba(255,255,255,.45), 0 5px 10px rgba(0,0,0,.22); }
.connections-visual-grid i:nth-child(-n+4) { background: #ffd166; }
.connections-visual-grid i:nth-child(n+5):nth-child(-n+8) { background: #5ee0a0; }
.connections-visual-grid i:nth-child(n+9):nth-child(-n+12) { background: #5ac8fa; }
.connections-visual-grid i:nth-child(n+13) { background: #b69cff; }
.connections-visual-label { position: absolute; right: 1rem; bottom: .75rem; color: rgba(255,255,255,.6); font-size: .62rem; font-weight: 900; letter-spacing: .16em; text-transform: uppercase; }
.crossword-visual { display: grid; place-items: center; background: linear-gradient(150deg,#f4dca1,#c98c45); }
.crossword-visual::before { content: ""; position: absolute; inset: 0; opacity: .12; background-image: linear-gradient(#412911 1px,transparent 1px),linear-gradient(90deg,#412911 1px,transparent 1px); background-size: 18px 18px; }
.crossword-grid { display: grid; width: 10rem; grid-template-columns: repeat(8,1fr); gap: 2px; transform: rotate(-3deg); filter: drop-shadow(0 12px 12px rgba(71,38,10,.28)); }
.crossword-grid i { display: grid; aspect-ratio: 1; place-items: center; background: #312419; color: transparent; font-size: .62rem; font-style: normal; font-weight: 950; }
.crossword-grid i.has-letter { color: #322112; background: #fff9e9; }
.crossword-pencil { position: absolute; right: 1.2rem; bottom: .55rem; color: #6d3e17; font-size: 3rem; transform: rotate(-24deg); text-shadow: 0 5px 8px rgba(64,35,10,.2); }
.arcade-cabinet-crossword .arcade-cabinet-status { color: #f3bb62; }
.blocks-visual { background: linear-gradient(#071a20,#0a3436); }
.blocks-visual::before { content: ""; position: absolute; inset: 0; opacity: .18; background-image: linear-gradient(rgba(74,222,128,.45) 1px,transparent 1px),linear-gradient(90deg,rgba(74,222,128,.45) 1px,transparent 1px); background-size: 24px 24px; }
.blocks-piece { position: absolute; display: grid; gap: 3px; }
.blocks-piece i,.blocks-floor i { border: 1px solid rgba(255,255,255,.35); border-radius: 4px; box-shadow: inset 0 0 8px rgba(255,255,255,.25),0 0 13px currentColor; }
.blocks-piece-a { left: 32%; top: 18%; grid-template-columns: repeat(2,1.8rem); color: #38e6c5; animation: arcadeBlockHover 2s ease-in-out infinite; }
.blocks-piece-a i { width: 1.8rem; height: 1.8rem; background: #0fcbaa; }
.blocks-piece-b { right: 18%; top: 42%; grid-template-columns: repeat(3,1.55rem); color: #ff4f91; transform: rotate(90deg); }
.blocks-piece-b i { width: 1.55rem; height: 1.55rem; background: #ed397e; }
.blocks-floor { position: absolute; right: 12%; bottom: 1rem; left: 12%; display: grid; grid-template-columns: repeat(6,1fr); gap: 3px; }
.blocks-floor i { aspect-ratio: 1; color: #ffc857; background: #eaaa2e; }
.blocks-floor i:nth-child(3n) { color: #6c7cff; background: #5868ee; transform: translateY(-1.5rem); }
.blocks-arrow { position: absolute; left: 19%; top: 20%; color: rgba(103,232,211,.5); font-size: 2.2rem; animation: arcadeArrowDown 1.3s ease-in-out infinite; }
.arcade-cabinet-falling-blocks .arcade-cabinet-status { color: #49ddb6; }
.asteroid-visual { background: radial-gradient(circle at 72% 28%,#253c74,#101735 45%,#070b19); }
.arcade-star { position: absolute; width: 3px; height: 3px; border-radius: 50%; background: white; box-shadow: 0 0 8px white; }
.star-a { left: 16%; top: 18%; }.star-b { right: 18%; top: 12%; }.star-c { left: 48%; top: 39%; }
.asteroid { position: absolute; border: 2px solid #8c91a6; border-radius: 47% 53% 42% 58%; background: radial-gradient(circle at 32% 28%,#9299ac,#4c5268 62%,#292e41); box-shadow: inset -7px -8px 12px rgba(0,0,0,.35),0 8px 18px rgba(0,0,0,.35); }
.asteroid::after { content: ""; position: absolute; width: 28%; height: 24%; left: 18%; top: 22%; border-radius: 50%; background: rgba(32,37,55,.45); }
.asteroid-a { width: 3.2rem; height: 3rem; right: 14%; top: 18%; transform: rotate(18deg); }.asteroid-b { width: 2rem; height: 2rem; left: 16%; top: 27%; }.asteroid-c { width: 1.4rem; height: 1.35rem; right: 32%; bottom: 18%; }
.space-station { position: absolute; left: 20%; bottom: 18%; width: 4rem; height: 1.4rem; border-radius: 65% 20% 35% 65%; background: linear-gradient(90deg,#b9c8ef,#6f83bd); transform: rotate(-12deg); box-shadow: 0 0 20px rgba(105,151,255,.35); }
.space-station::before { content: ""; position: absolute; left: 1.2rem; bottom: 1rem; border-right: 1rem solid transparent; border-bottom: 1.5rem solid #53699f; border-left: .25rem solid transparent; }
.space-station i { position: absolute; right: -.75rem; top: .3rem; width: 1rem; height: .8rem; border-radius: 50%; background: #50e6ff; box-shadow: 0 0 16px #4fdff8; }
.laser-beam { position: absolute; width: 38%; height: 2px; left: 42%; top: 52%; background: linear-gradient(90deg,#70f4ff,transparent); transform: rotate(-20deg); transform-origin: left; box-shadow: 0 0 8px #52e7ff; }
.shield-ring { position: absolute; left: 8%; bottom: 4%; width: 7.5rem; height: 6rem; border: 2px solid rgba(90,221,255,.25); border-radius: 50%; transform: rotate(-15deg); }
.arcade-cabinet-asteroid-defense .arcade-cabinet-status { color: #65c9ff; }
@keyframes arcadeBlockHover { 0%,100% { transform: translateY(0); } 50% { transform: translateY(8px); } }
@keyframes arcadeArrowDown { 0%,100% { opacity: .3; transform: translateY(-5px); } 50% { opacity: .8; transform: translateY(6px); } }
/* Connections has its own playful game-show visual system, independent of the desk UI. */
.connections-world {
--theme-bg-surface: #182443;
--theme-bg-surface-alt: #223154;
--theme-bg-callout: #2c3d68;
--theme-primary: #ffd166;
--theme-primary-hover: #ffe29a;
--theme-text-heading: #f8fbff;
--theme-text-body: #dbe7ff;
--theme-text-secondary: #afc0df;
--theme-text-muted: #8497bd;
--theme-border: #52668d;
--theme-border-light: #33486f;
position: relative;
isolation: isolate;
overflow: hidden;
color: var(--theme-text-body);
background:
radial-gradient(circle at 10% 0%, rgba(88, 101, 242, .45), transparent 28rem),
radial-gradient(circle at 95% 25%, rgba(239, 71, 111, .2), transparent 25rem),
linear-gradient(145deg, #101a35 0%, #111d39 48%, #172846 100%);
box-shadow: 0 28px 80px rgba(8, 15, 34, .28);
}
.connections-hub.connections-world,
.connections-stage.connections-world {
overflow: visible;
background: transparent;
box-shadow: none;
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
.connections-hub.connections-world::before,
.connections-stage.connections-world::before { display: none; }
.connections-world::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
opacity: .13;
background-image:
linear-gradient(rgba(255,255,255,.18) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.18) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: linear-gradient(to bottom, black, transparent 78%);
}
/* ── Card flip animation ── */
.perspective-1000 {
perspective: 1000px;
.connections-world .editorial-title {
font-family: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
font-weight: 850;
letter-spacing: -.045em;
}
.preserve-3d {
transform-style: preserve-3d;
.connections-hub-hero,
.connections-topbar,
.connections-status,
.connections-setup,
.connections-import {
backdrop-filter: blur(18px);
box-shadow: inset 0 1px rgba(255,255,255,.08), 0 18px 45px rgba(2, 8, 23, .22);
}
.backface-hidden {
backface-visibility: hidden;
.connections-hub-hero {
background: linear-gradient(120deg, rgba(34,49,84,.94), rgba(24,36,67,.72));
border: 1px solid rgba(151, 174, 221, .18);
}
.rotate-y-180 {
transform: rotateY(180deg);
.connections-pack-card {
border-color: rgba(117, 141, 187, .28);
background: rgba(24, 36, 67, .74);
box-shadow: inset 0 1px rgba(255,255,255,.05), 0 12px 30px rgba(3, 9, 24, .16);
}
.rotate-x-180 {
transform: rotateX(180deg);
.connections-pack-card:hover {
transform: translateY(-2px);
border-color: rgba(255, 209, 102, .48);
}
/* ── Swipe animations ── */
@keyframes swipeLeft {
0% { transform: translateX(0) rotate(0deg); opacity: 1; }
100% { transform: translateX(-40px) rotate(-8deg); opacity: 0; }
.connections-pack-card.is-active {
border-color: #ffd166;
background: linear-gradient(120deg, rgba(65, 72, 132, .9), rgba(38, 55, 96, .9));
box-shadow: 0 0 0 1px rgba(255,209,102,.25), 0 18px 45px rgba(3,9,24,.25);
}
@keyframes swipeRight {
0% { transform: translateX(0) rotate(0deg); opacity: 1; }
100% { transform: translateX(40px) rotate(8deg); opacity: 0; }
.connections-primary-button {
color: #172033;
background: linear-gradient(135deg, #ffd166, #ffb84d);
box-shadow: 0 10px 26px rgba(255, 184, 77, .24), inset 0 1px rgba(255,255,255,.5);
transition: transform .18s var(--ease-spring), filter .18s ease, box-shadow .18s ease;
}
.animate-swipe-left {
animation: swipeLeft 0.4s var(--ease-spring) forwards;
.connections-primary-button:not(:disabled):hover {
transform: translateY(-2px) scale(1.01);
filter: brightness(1.06);
box-shadow: 0 14px 34px rgba(255, 184, 77, .32), inset 0 1px rgba(255,255,255,.6);
}
.animate-swipe-right {
animation: swipeRight 0.4s var(--ease-spring) forwards;
.connections-secondary-button {
color: #dbe7ff;
border: 1px solid #52668d;
background: rgba(24, 36, 67, .82);
transition: transform .18s var(--ease-spring), background .18s ease;
}
/* ── Modal animation ── */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
.connections-secondary-button:not(:disabled):hover { transform: translateY(-2px); background: #2c3d68; }
.connections-tile {
color: #15213a;
border: 1px solid rgba(255,255,255,.75);
background: linear-gradient(145deg, #fffaf0, #e9effa);
box-shadow: 0 7px 0 #aebbd2, 0 12px 24px rgba(2,8,23,.24), inset 0 1px #fff;
animation: connectionsTileIn .36s var(--ease-spring) both;
transition: transform .16s var(--ease-spring), box-shadow .16s ease, color .16s ease, background .16s ease;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
.connections-tile:hover { transform: translateY(-3px); box-shadow: 0 9px 0 #aebbd2, 0 16px 30px rgba(2,8,23,.28), inset 0 1px #fff; }
.connections-tile.is-selected {
color: #fff;
border-color: #8c7cf7;
background: linear-gradient(145deg, #735df2, #5540ca);
box-shadow: 0 5px 0 #30228e, 0 12px 28px rgba(84,64,202,.4), inset 0 1px rgba(255,255,255,.28);
transform: translateY(2px) scale(.975);
animation: connectionsTileSelect .25s var(--ease-spring);
}
.animate-fade-in {
animation: fadeIn 0.2s ease-out;
.connections-group,
.connections-review-card {
color: #172033;
box-shadow: inset 0 1px rgba(255,255,255,.55), 0 10px 26px rgba(2,8,23,.2);
}
.connections-group .text-text-heading,
.connections-group .text-text-secondary,
.connections-review-card .text-text-heading,
.connections-review-card .text-text-secondary { color: #172033; }
.connections-group { animation: connectionsGroupReveal .56s var(--ease-spring) both; }
.connections-group-0 { background: linear-gradient(135deg, #ffd166, #f7b944); }
.connections-group-1 { background: linear-gradient(135deg, #70e1a1, #36bd7c); }
.connections-group-2 { background: linear-gradient(135deg, #72d5f7, #4aa8e8); }
.connections-group-3 { background: linear-gradient(135deg, #b9a2ff, #8d73ed); }
.connections-results-hero {
background: linear-gradient(130deg, #5b46d8, #283d77 58%, #164e63);
box-shadow: inset 0 1px rgba(255,255,255,.18), 0 22px 50px rgba(2,8,23,.3);
}
.animate-slide-up {
animation: slideUp 0.3s var(--ease-spring);
.connections-completion {
position: relative;
display: grid;
min-height: 32rem;
place-content: center;
overflow: hidden;
border-radius: 1.75rem;
text-align: center;
background: radial-gradient(circle, rgba(94,234,212,.2), transparent 42%), rgba(10,18,39,.72);
animation: connectionsCompletionIn .55s var(--ease-spring) both;
}
.connections-completion-mark {
display: grid;
width: 6rem;
height: 6rem;
margin: 0 auto 1.5rem;
place-items: center;
border-radius: 999px;
color: #172033;
background: #ffd166;
box-shadow: 0 0 0 12px rgba(255,209,102,.12), 0 0 65px rgba(255,209,102,.46);
font-size: 3rem;
font-weight: 900;
animation: connectionsWinMark .8s .15s var(--ease-spring) both;
}
.connections-completion p { color: #8ee7d1; font-size: .75rem; font-weight: 850; letter-spacing: .2em; text-transform: uppercase; }
.connections-completion h2 { margin-top: .35rem; color: white; font-size: clamp(2.5rem, 8vw, 5.5rem); font-weight: 900; letter-spacing: -.065em; line-height: .95; }
.connections-completion span { margin-top: 1rem; color: #b9c8e5; font-weight: 700; }
.connections-confetti { position: absolute; inset: 0; pointer-events: none; }
.connections-confetti i {
position: absolute;
top: -8%;
left: var(--confetti-x);
width: 9px;
height: 17px;
border-radius: 2px;
background: var(--confetti-color);
animation: connectionsConfettiFall 1.45s var(--confetti-delay) cubic-bezier(.2,.7,.3,1) both;
}
/* ── Subtle pulse for loading states ── */
@keyframes subtlePulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
@keyframes connectionsTileIn { from { opacity: 0; transform: translateY(16px) scale(.94); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes connectionsTileSelect { 0% { transform: scale(1); } 55% { transform: translateY(3px) scale(.94); } 100% { transform: translateY(2px) scale(.975); } }
@keyframes connectionsShake { 0%,100% { transform: translateX(0); } 20% { transform: translateX(-9px); } 40% { transform: translateX(8px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(3px); } }
@keyframes connectionsGroupReveal { from { opacity: 0; transform: scale(.9) translateY(18px); filter: brightness(1.6); } 65% { transform: scale(1.025) translateY(-2px); } to { opacity: 1; transform: scale(1) translateY(0); filter: brightness(1); } }
@keyframes connectionsCompletionIn { from { opacity: 0; transform: scale(.94); } to { opacity: 1; transform: scale(1); } }
@keyframes connectionsWinMark { from { opacity: 0; transform: scale(.2) rotate(-25deg); } 70% { transform: scale(1.12) rotate(4deg); } to { opacity: 1; transform: scale(1) rotate(0); } }
@keyframes connectionsConfettiFall { 0% { opacity: 0; transform: translateY(-1rem) rotate(0); } 10% { opacity: 1; } 100% { opacity: 1; transform: translateY(38rem) rotate(var(--confetti-spin)); } }
@keyframes connectionsResultsIn { from { opacity: 0; transform: translateY(22px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
.animate-connections-shake { animation: connectionsShake .42s ease-in-out; }
.animate-connections-results-in { animation: connectionsResultsIn .52s var(--ease-spring) both; }
/* Crossword turns the full arcade shell into a warm, ink-and-paper newsroom. */
body:has(.crossword-world) {
--theme-bg-base: #20170f;
--theme-bg-surface: #f6ecd2;
--theme-bg-surface-alt: #ead9b5;
--theme-bg-callout: #dfc795;
--theme-primary: #9a5a24;
--theme-primary-hover: #7b431b;
--theme-text-heading: #2d2117;
--theme-text-body: #493725;
--theme-text-secondary: #68523a;
--theme-text-muted: #8a7052;
--theme-border: #a9865d;
--theme-border-light: #cfb88e;
background:
radial-gradient(circle at 18% 8%, rgba(230, 181, 88, .18), transparent 28rem),
linear-gradient(135deg, #1d140d, #3c2818 55%, #21160e);
}
body:has(.crossword-world)::before {
opacity: .16;
background-image: repeating-linear-gradient(0deg, transparent 0 3px, rgba(255,236,195,.16) 4px);
}
body:has(.crossword-world) .app-sidebar,
body:has(.crossword-world) .app-mobile-header {
--theme-bg-surface-alt: #3a2819;
--theme-bg-callout: #e4cc98;
--theme-primary: #8a4f20;
--theme-text-heading: #fff3d8;
--theme-text-secondary: #d7c3a2;
--theme-text-muted: #aa9170;
--theme-border-light: #5b4028;
color: #f8ead0;
border-color: rgba(224, 190, 126, .22);
background: rgba(35, 23, 14, .94);
}
.animate-subtle-pulse {
animation: subtlePulse 2s ease-in-out infinite;
.crossword-world {
position: relative;
color: var(--theme-text-body);
}
.crossword-hub.crossword-world,
.crossword-stage.crossword-world { overflow: visible; }
.crossword-hero,
.crossword-setup,
.crossword-active-clue,
.crossword-clues,
.crossword-import,
.crossword-paper {
border: 1px solid rgba(100, 68, 35, .24);
background:
linear-gradient(rgba(255,255,255,.18), rgba(255,255,255,0)),
repeating-linear-gradient(0deg, rgba(83,54,24,.025) 0 1px, transparent 1px 4px),
#f4e7c9;
box-shadow: inset 0 1px rgba(255,255,255,.7), 0 18px 48px rgba(15,9,4,.25);
}
.crossword-hero { position: relative; overflow: hidden; }
.crossword-hero > * { position: relative; z-index: 1; }
.crossword-hero::after {
content: "DAILY STUDY";
position: absolute;
right: 1rem;
top: .35rem;
color: rgba(77,51,28,.07);
font-family: Georgia, serif;
font-size: clamp(2.75rem, 5vw, 5rem);
font-weight: 900;
letter-spacing: -.06em;
line-height: 1;
pointer-events: none;
}
.crossword-kicker,
.crossword-section-label {
color: #925623;
font-size: .68rem;
font-weight: 900;
letter-spacing: .18em;
text-transform: uppercase;
}
.crossword-section-label { display: block; margin-bottom: .7rem; color: var(--theme-text-muted); }
.crossword-primary {
color: #fff8e7;
background: linear-gradient(135deg, #a86429, #744018);
box-shadow: inset 0 1px rgba(255,255,255,.24), 0 7px 0 #4a2912, 0 12px 24px rgba(49,27,12,.22);
transition: transform .16s var(--ease-spring), filter .16s ease;
}
.crossword-primary:hover { transform: translateY(-2px); filter: brightness(1.08); }
.crossword-primary:active { transform: translateY(4px); box-shadow: inset 0 1px rgba(255,255,255,.2), 0 3px 0 #4a2912; }
.crossword-pack {
border-color: rgba(111,76,43,.25);
background: rgba(244,231,201,.92);
box-shadow: 0 10px 26px rgba(20,12,6,.16), inset 0 1px rgba(255,255,255,.65);
transition: transform .18s ease, border-color .18s ease;
}
.crossword-pack:hover { transform: translateY(-2px); border-color: #a86429; }
.crossword-pack.is-active { border-color: #8d511f; box-shadow: 0 0 0 2px rgba(168,100,41,.2), 0 14px 30px rgba(20,12,6,.2); }
.crossword-paper > div i { aspect-ratio: 1; border: 1px solid rgba(45,33,23,.3); }
.crossword-size {
color: var(--theme-text-secondary);
border: 1px solid var(--theme-border-light);
background: rgba(255,250,235,.48);
}
.crossword-size.is-active { color: #2d2117; border-color: #955720; background: #e4c993; box-shadow: inset 0 0 0 2px rgba(255,255,255,.35); }
.crossword-board-preview { overflow: hidden; border: 1px solid #c9aa78; border-radius: .9rem; background: rgba(255,250,235,.48); }
.crossword-preview-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .65rem .8rem; border-bottom: 1px solid #d5bd93; color: #5d4630; }
.crossword-preview-heading span { font-size: .68rem; font-weight: 900; letter-spacing: .14em; text-transform: uppercase; }
.crossword-preview-heading small { color: #8a7052; font-size: .68rem; }
.crossword-preview-canvas { display: grid; min-height: 9rem; place-items: center; overflow: hidden; padding: .75rem; background: #322116; }
.crossword-preview-grid { display: grid; filter: drop-shadow(0 6px 9px rgba(0,0,0,.35)); }
.crossword-preview-grid i { border: .5px solid #9e8058; background: #fff6de; }
.crossword-preview-loading,.crossword-preview-canvas > p { color: #d7c3a2; font-size: .75rem; font-weight: 800; }
.crossword-option { display: flex; align-items: flex-start; gap: .7rem; padding: .75rem; border-radius: .75rem; background: rgba(224,202,157,.38); }
.crossword-option input { margin-top: .2rem; accent-color: #925623; }
.crossword-option strong,.crossword-option small { display: block; }
.crossword-option small { margin-top: .12rem; color: var(--theme-text-muted); }
.crossword-active-clue { display: grid; gap: .2rem; }
.crossword-active-clue span { color: #925623; font-size: .68rem; font-weight: 900; letter-spacing: .15em; text-transform: uppercase; }
.crossword-active-clue strong { color: #2d2117; font-family: Georgia, serif; font-size: 1.05rem; }
.crossword-active-clue small { color: var(--theme-text-muted); }
.crossword-toolbar { color: #f4e7c9; background: rgba(35,23,14,.9); box-shadow: 0 8px 24px rgba(13,8,4,.24); }
.crossword-toolbar > div { display: flex; align-items: center; gap: .35rem; }
.crossword-toolbar button { display: grid; width: 2rem; height: 2rem; place-items: center; border: 1px solid rgba(245,222,179,.25); border-radius: .45rem; font-weight: 900; }
.crossword-toolbar span { min-width: 3rem; text-align: center; font-size: .75rem; font-weight: 800; }
.crossword-board-viewport {
max-height: min(68vh, 46rem);
overflow: auto;
padding: 1rem;
overscroll-behavior: contain;
background: #2a1d13;
box-shadow: inset 0 0 0 5px #4c321e, inset 0 0 28px rgba(0,0,0,.7), 0 16px 38px rgba(13,8,4,.3);
}
.crossword-board { display: grid; width: max-content; min-width: 100%; min-height: 20rem; place-content: center; }
.crossword-cell {
position: relative;
display: grid;
width: var(--crossword-cell);
height: var(--crossword-cell);
place-items: center;
color: #25190f;
border: 1px solid #6f5335;
background: #fff8e6;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
transition: background .12s ease, box-shadow .12s ease;
}
.crossword-cell small { position: absolute; left: 2px; top: 0; font-size: max(7px, calc(var(--crossword-cell) * .22)); line-height: 1; }
.crossword-cell strong { font-size: max(12px, calc(var(--crossword-cell) * .56)); }
.crossword-cell.is-word { background: #f2d99f; }
.crossword-cell.is-active { z-index: 2; background: #ffe27e; box-shadow: inset 0 0 0 3px #925623; }
.crossword-cell.is-wrong::after { content: ""; position: absolute; right: 3px; bottom: 3px; width: 7px; height: 7px; border: 2px solid #a62e25; border-radius: 50%; }
.crossword-cell.is-revealed strong { color: #8a5a24; text-decoration: underline dotted; }
.crossword-secondary {
min-height: 2.75rem;
padding: .6rem .9rem;
border: 1px solid #9f7a50;
border-radius: .7rem;
color: #392718;
background: #ead6ae;
font-size: .78rem;
font-weight: 850;
box-shadow: 0 3px 0 #aa895f;
}
.crossword-secondary:hover { background: #f5e5c4; }
.crossword-clues { max-height: 72vh; overflow: hidden; }
.crossword-clues > div button { min-height: 2.5rem; border-bottom: 2px solid transparent; color: var(--theme-text-muted); font-size: .75rem; font-weight: 900; text-transform: uppercase; }
.crossword-clues > div button.is-active { color: #7d461d; border-color: #9a5a24; }
.crossword-clues ol { max-height: calc(72vh - 5rem); overflow-y: auto; padding-right: .25rem; }
.crossword-clues li button { display: grid; width: 100%; grid-template-columns: 2rem 1fr; gap: .4rem; padding: .55rem; border-radius: .5rem; text-align: left; color: #4b3826; }
.crossword-clues li button:hover,.crossword-clues li button.is-active { background: #dec38c; }
.crossword-clues li b { color: #8a4f20; }
.crossword-mobile-input { position: fixed; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.crossword-results-hero { color: #f8ead0; background: linear-gradient(135deg, #3b2819, #6d421f); box-shadow: 0 18px 45px rgba(15,9,4,.3); }
.crossword-results-hero > div > div { background: rgba(255,244,220,.1); }
.crossword-results-hero small,.crossword-results-hero strong { display: block; }
.crossword-results-hero small { color: rgba(255,244,220,.6); font-size: .65rem; font-weight: 900; text-transform: uppercase; }
.crossword-results-hero strong { margin-top: .2rem; font-size: 1.25rem; }
.crossword-final-board { border: 1px solid #c8aa79; color: #392718; background: #f4e7c9; box-shadow: 0 12px 32px rgba(20,12,6,.18); }
.crossword-final-legend { display: flex; flex-wrap: wrap; gap: .8rem; color: #6a5139; font-size: .72rem; font-weight: 800; }
.crossword-final-legend span { display: inline-flex; align-items: center; gap: .35rem; }
.crossword-final-legend i { width: .8rem; height: .8rem; border: 1px solid currentColor; border-radius: .15rem; }
.crossword-final-legend i.is-correct { color: #356b3b; background: #bfe0b9; }
.crossword-final-legend i.is-assisted { color: #8a631c; background: #f2d47e; }
.crossword-final-legend i.is-incorrect { color: #923a30; background: #efb9af; }
.crossword-final-viewport { overflow: auto; padding: 1rem; border-radius: 1rem; background: #2a1d13; box-shadow: inset 0 0 22px rgba(0,0,0,.55); }
.crossword-final-grid { display: grid; width: max-content; min-width: 100%; place-content: center; }
.crossword-final-cell { position: relative; display: grid; place-items: center; color: #25190f; border: 1px solid #62482f; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.crossword-final-cell.is-correct { background: #c7e6bf; box-shadow: inset 0 0 0 1px rgba(47,108,58,.35); }
.crossword-final-cell.is-assisted { background: #f2d47e; box-shadow: inset 0 0 0 2px rgba(138,99,28,.42); }
.crossword-final-cell.is-incorrect { background: #efbbb1; box-shadow: inset 0 0 0 2px rgba(145,47,39,.45); }
.crossword-final-cell small { position: absolute; left: 2px; top: 1px; font-size: 7px; line-height: 1; }
.crossword-final-cell strong { font-size: clamp(10px, 2vw, 15px); }
.crossword-review { border: 1px solid #c8aa79; color: #392718; background: #f4e7c9; box-shadow: 0 8px 22px rgba(20,12,6,.15); }
.crossword-review.is-incorrect { border-left: 6px solid #a63a2e; }
.crossword-review.is-correct { border-left: 6px solid #4f7a4f; }
.crossword-review.is-assisted { border-left: 6px solid #b58627; }
.crossword-review.is-omitted { opacity: .75; border-left: 6px solid #8c765a; }
@media (max-width: 639px) {
.crossword-board-viewport { margin-inline: -.25rem; padding: .6rem; }
.crossword-board { place-content: start; min-height: 24rem; }
.crossword-clues { max-height: 28rem; }
.crossword-hero::after { top: .75rem; right: .75rem; font-size: 2.75rem; }
.crossword-final-viewport { margin-inline: -.25rem; padding: .65rem; }
}
/* ── Markdown content styles ── */
.markdown-content h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.5rem; color: var(--color-text-heading); }
.markdown-content h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--color-text-heading); }
.markdown-content h3 { font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--color-text-heading); }
.markdown-content p { margin-bottom: 0.75rem; line-height: 1.7; }
.markdown-content ul { list-style: disc inside; padding-left: 0; margin-bottom: 0.75rem; text-align: inherit; }
.markdown-content ol { list-style: decimal inside; padding-left: 0; margin-bottom: 0.75rem; text-align: inherit; }
.markdown-content li { margin-bottom: 0.25rem; line-height: 1.6; }
.markdown-content strong { font-weight: 600; color: var(--color-text-heading); }
.markdown-content code { background: var(--color-bg-surface-alt); padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.875rem; }
.markdown-content table { border-collapse: collapse; width: 100%; margin-bottom: 0.75rem; }
.markdown-content th { background: var(--color-bg-surface-alt); font-weight: 600; text-align: left; padding: 0.5rem 0.75rem; border: 1px solid var(--color-border-light); }
.markdown-content td { padding: 0.5rem 0.75rem; border: 1px solid var(--color-border-light); }
.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); }
.markdown-content p { margin-bottom: .75rem; line-height: 1.72; }
.markdown-content ul { list-style: disc; padding-left: 1.35rem; margin-bottom: .75rem; }
.markdown-content ol { list-style: decimal; padding-left: 1.35rem; margin-bottom: .75rem; }
.markdown-content li { margin-bottom: .25rem; line-height: 1.65; }
.markdown-content strong { font-weight: 700; color: var(--color-text-heading); }
.markdown-content code { background: var(--color-bg-surface-alt); padding: .15rem .4rem; border-radius: .35rem; font-size: .875rem; }
.markdown-content table { border-collapse: collapse; width: 100%; margin-bottom: .75rem; }
.markdown-content th { background: var(--color-bg-surface-alt); font-weight: 650; text-align: left; padding: .6rem .8rem; border: 1px solid var(--color-border-light); }
.markdown-content td { padding: .6rem .8rem; border: 1px solid var(--color-border-light); }
.markdown-content *:last-child { margin-bottom: 0; }
@media (max-width: 767px) {
.app-page { padding-top: 1.25rem; padding-bottom: 6rem; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}

View file

@ -1,16 +1,22 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { Manrope, Newsreader } from "next/font/google";
import "./globals.css";
const inter = Inter({
variable: "--font-inter",
const manrope = Manrope({
variable: "--font-manrope",
subsets: ["latin"],
display: "swap",
});
const newsreader = Newsreader({
variable: "--font-newsreader",
subsets: ["latin"],
display: "swap",
});
export const metadata: Metadata = {
title: "Study App",
description: "Practice exams and quizzes",
title: "Study Desk",
description: "A focused workspace for flashcards and practice quizzes",
};
export default function RootLayout({
@ -19,13 +25,16 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" className={`${inter.variable} h-full`} suppressHydrationWarning>
<html lang="en" className={`${manrope.variable} ${newsreader.variable} h-full`} data-scroll-behavior="smooth" suppressHydrationWarning>
<head>
<script
id="theme-init"
dangerouslySetInnerHTML={{
__html: `
try {
if (localStorage.theme === 'dark') {
const savedTheme = localStorage.theme
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
@ -35,7 +44,7 @@ export default function RootLayout({
}}
/>
</head>
<body className="min-h-full flex flex-col font-sans antialiased">
<body className="min-h-full flex flex-col font-sans antialiased" suppressHydrationWarning>
{children}
</body>
</html>

View file

@ -1,166 +1,333 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
type LoginStage = "login" | "token" | "password" | "success";
async function postJson(path: string, body?: object) {
const response = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {}),
});
const data: { error?: string } = await response.json();
if (!response.ok) throw new Error(data.error || "Request failed");
}
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 [isSetup, setIsSetup] = useState<boolean | null>(null);
const router = useRouter();
const [setupRequired, setSetupRequired] = useState<boolean | null>(null);
useEffect(() => {
fetch("/api/auth/setup-status")
.then((res) => res.json())
.then((data) => setIsSetup(data.setupRequired))
.catch(() => setIsSetup(false)); // fallback
.then((response) => response.json())
.then((data) => setSetupRequired(data.setupRequired))
.catch(() => setSetupRequired(false));
}, []);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
async function run(action: () => Promise<void>) {
setError("");
setLoading(true);
try {
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.");
await action();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Network error. Please try again.");
} finally {
setLoading(false);
}
}
if (isSetup === null) {
function handleLogin(event: React.FormEvent) {
event.preventDefault();
void run(async () => {
await postJson("/api/auth/login", { password });
window.location.href = "/";
});
}
function beginReset() {
void run(async () => {
await postJson("/api/auth/password-reset/request");
setToken("");
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) {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<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 className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-sm animate-fade-in">
{/* Logo / Title area */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary/10 mb-4">
<svg
className="w-8 h-8 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-text-heading">
{isSetup ? "Welcome to Study App!" : "Study App"}
</h1>
<p className="text-text-muted mt-1 text-sm">
{isSetup
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."
: "Enter your password to continue"}
</p>
</div>
: "Open your notes, decks, and practice quizzes.";
{/* Login card */}
<div className="bg-bg-surface rounded-xl shadow-[var(--shadow-card)] border border-border-light p-6">
<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
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="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>
<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">
<PasswordField
id="password"
type="password"
label="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={isSetup ? "Create a new password" : "Enter your password"}
onChange={setPassword}
placeholder={setupRequired ? "Create a new password" : "Enter your password"}
autoFocus
className="w-full px-3.5 py-2.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200"
/>
</div>
{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"
>
<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>
)}
<ErrorMessage message={error} />
<PrimaryButton loading={loading} disabled={password.length < 8}>
{setupRequired ? "Save Password & Login" : "Sign In"}
</PrimaryButton>
{!setupRequired && (
<button
type="submit"
disabled={loading || !password}
className="w-full py-2.5 px-4 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 cursor-pointer"
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"
>
{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"
)}
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}
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"}`}
>
{valid ? validText : invalidText}
</p>
);
}
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"
>
{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}
</button>
);
}

View file

@ -4,8 +4,9 @@ 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 }: { data: any }) {
export function SharedGroupViewer({ data }: { data: SharedGroupData }) {
const pathname = usePathname();
const router = useRouter();
@ -18,7 +19,7 @@ export function SharedGroupViewer({ data }: { data: any }) {
if (typeof window === 'undefined') return;
const newProgress: Record<string, { currentIndex: number, total: number }> = {};
items.forEach((item: any) => {
items.forEach((item) => {
const key = data.type === "DECK" ? `flashcard_progress_${item.id}` : `quiz_progress_${item.id}`;
const saved = localStorage.getItem(key);
if (saved) {
@ -71,7 +72,7 @@ export function SharedGroupViewer({ data }: { data: any }) {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{items.map((item: any) => (
{items.map((item) => (
<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"

View file

@ -6,10 +6,11 @@ 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: string;
data: any;
type: "flashcards" | "quizzes";
data: SharedStudyItem;
groupMode?: boolean;
}
@ -36,7 +37,7 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp
const [showTopics, setShowTopics] = useState(false);
const categories = type === "quizzes" && data.questions
? Array.from(new Set(data.questions.map((q: any) => q.category.toUpperCase()))).join(" · ")
? Array.from(new Set(data.questions.map((q) => q.category.toUpperCase()))).join(" · ")
: "";
return (
@ -137,18 +138,20 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp
view === "study" ? (
<FlashcardViewer
key={restartKey}
cards={data.cards}
cards={data.cards ?? []}
deckId={data.id}
isShared={true}
/>
) : (
<CardList cards={data.cards} />
<CardList cards={data.cards ?? []} />
)
) : (
<QuizViewer
key={restartKey}
quiz={{
...data,
id: data.id,
name: data.name,
questions: data.questions ?? [],
progress: [], // Start fresh since it's shared
}}
retakeIds={null}

View file

@ -1,9 +1,12 @@
import { getShareLink } from "@/services/shareService";
import { notFound } from "next/navigation";
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 type { SharedGroupData, SharedStudyItem } from "@/types/study";
type SharedContentType = "flashcards" | "quizzes";
interface SharedPageProps {
params: Promise<{
@ -27,6 +30,16 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
notFound();
}
// Older inherited-share URLs used a group token with a deck/quiz path.
// They did not include an item ID, so preserve them by sending visitors to
// the owning group where the intended item can be selected.
if ((type === "flashcards" || type === "quizzes") && link.group) {
if (link.group.class.slug !== classSlug) {
notFound();
}
redirect(`/shared/${classSlug}/groups/${token}`);
}
// Validate that the link matches the URL structure
if (type === "flashcards" && !link.deck) notFound();
if (type === "quizzes" && !link.quizSet) notFound();
@ -38,17 +51,17 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
notFound();
}
let targetData: any = null;
let targetType = type;
let targetData: SharedStudyItem | SharedGroupData | null = null;
let targetType: SharedContentType = type === "flashcards" ? "flashcards" : "quizzes";
if (type === "groups" && link.group) {
if (itemId) {
// Find the specific item inside the group
if (link.group.type === "DECK") {
targetData = link.group.decks.find(d => d.id === itemId);
targetData = link.group.decks.find(d => d.id === itemId) ?? null;
targetType = "flashcards";
} else {
targetData = link.group.quizSets.find(q => q.id === itemId);
targetData = link.group.quizSets.find(q => q.id === itemId) ?? null;
targetType = "quizzes";
}
if (!targetData) notFound();
@ -59,16 +72,19 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
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 */}
<header className="bg-bg-surface border-b border-border-light shadow-sm sticky top-0 z-10">
<header className="sticky top-0 z-10 border-b border-border-light bg-bg-surface/88 shadow-sm backdrop-blur-xl">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-14">
<div className="flex items-center gap-2.5 text-text-heading font-semibold">
<div className="flex items-center gap-2.5 font-semibold text-text-heading">
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<span className="grid h-8 w-8 place-items-center rounded-xl bg-primary text-xs font-black text-white">S</span>
Shared {type === "groups" ? "Group" : (type === "flashcards" ? "Deck" : "Quiz")}
</div>
@ -92,13 +108,13 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
{/* Main Content */}
<main className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<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} />
<SharedGroupViewer data={targetData as SharedGroupData} />
) : (
<SharedViewer
type={targetType}
data={targetData}
data={targetData as SharedStudyItem}
groupMode={type === "groups"}
/>
)}

View file

@ -0,0 +1,188 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
interface DailyActivity {
date: string;
flashcards: number;
questions: number;
arcade: number;
total: number;
level: 0 | 1 | 2 | 3 | 4;
}
interface ActivitySummary {
days: DailyActivity[];
today: string;
currentStreak: number;
}
const levelClasses = [
"bg-white/10",
"bg-primary/45",
"bg-primary",
"bg-accent",
"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] bg-[#172033] px-5 py-7 text-white shadow-[var(--shadow-card)] sm:px-8 sm:py-9 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 mb-6 flex items-center justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-[0.22em] text-white/55">Personal library</p>
<h1 className="editorial-title mt-1 text-3xl text-white sm:text-4xl">Study activity</h1>
</div>
<button onClick={onNewClass} className="inline-flex min-h-11 shrink-0 items-center justify-center gap-2 rounded-xl bg-white px-4 text-sm font-bold text-[#172033] shadow-lg transition-transform hover:-translate-y-0.5 sm:min-h-12 sm:px-5">
<span className="text-xl leading-none">+</span> New class
</button>
</div>
{error && (
<div className="relative rounded-xl border border-white/15 bg-white/8 px-4 py-5 text-sm 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-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-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: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-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-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-white/8 ${levelClasses[day.level]} focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 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-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-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-white/15 bg-[#0d1422] px-3 py-2 text-right text-xs shadow-xl">
<div className="font-bold text-white">{formatted} · {day.total} total</div>
<div className="mt-0.5 text-white/60">{day.flashcards} flashcards · {day.questions} questions · {day.arcade} arcade groups</div>
</div>
);
}
function StreakDisplay({ streak }: { streak: number }) {
const size = 24 + Math.min(streak, 30) * 0.6;
const color = streak === 0 ? "text-white/30" : streak < 7 ? "text-accent" : streak < 30 ? "text-[#ff7a36]" : "text-[#fbbf24]";
return (
<div className="flex items-center justify-center gap-4 rounded-2xl border border-white/12 bg-white/7 px-5 py-4 xl:flex-col xl:gap-2 xl:text-center">
<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-white/55">day streak</div>
<div className="mt-1 text-[10px] 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, and ${day.arcade} arcade groups, ${day.total} total activities`;
}

View file

@ -0,0 +1,38 @@
"use client";
import type { ReactNode } from "react";
import { useRouter } from "next/navigation";
export function ArcadeGameShell({
title,
exitHref,
elapsedSeconds,
complete,
worldClassName = "connections-world connections-stage",
children,
}: {
title: string;
exitHref: string;
elapsedSeconds: number;
complete: boolean;
worldClassName?: string;
children: ReactNode;
}) {
const router = useRouter();
function exitGame() {
if (!complete && !window.confirm("Leave this round? Your progress will not be saved.")) return;
router.push(exitHref);
}
const minutes = Math.floor(elapsedSeconds / 60);
const seconds = String(elapsedSeconds % 60).padStart(2, "0");
return (
<div className={`${worldClassName} mx-auto w-full max-w-6xl p-1 pb-16 sm:p-3`}>
<header className="arcade-game-topbar connections-topbar mb-5 flex items-center justify-between gap-4 rounded-2xl border border-border-light bg-bg-surface/85 px-4 py-3 shadow-sm">
<button onClick={exitGame} className="min-h-10 rounded-xl px-3 text-sm font-bold text-text-muted hover:bg-bg-surface-alt hover:text-text-heading"> Exit</button>
<h1 className="truncate text-center text-lg font-extrabold text-text-heading">{title}</h1>
<div className="min-w-16 text-right font-mono text-sm font-bold text-text-muted" aria-label={`${elapsedSeconds} seconds elapsed`}>{minutes}:{seconds}</div>
</header>
{children}
</div>
);
}

View file

@ -0,0 +1,127 @@
"use client";
import { useState } from "react";
import { GenerateTab } from "@/components/import/GenerateTab";
import type { ArcadeGameKey, ArcadeImportBatchPreview } from "@/types/arcade";
export function ArcadeImportModal({
classId,
gameType,
onClose,
onImported,
}: {
classId: string;
gameType: ArcadeGameKey;
onClose: () => void;
onImported: () => void;
}) {
const [tab, setTab] = useState<"generate" | "import">("import");
const [rawJson, setRawJson] = useState("");
const [names, setNames] = useState<string[]>([]);
const [preview, setPreview] = useState<ArcadeImportBatchPreview | null>(null);
const [error, setError] = useState("");
const [details, setDetails] = useState<string[]>([]);
const [acknowledged, setAcknowledged] = useState(false);
const [busy, setBusy] = useState(false);
async function previewImport() {
setBusy(true);
setError("");
setDetails([]);
setPreview(null);
try {
const response = await fetch("/api/arcade/import/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ gameType, rawJson }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error ?? "Import preview failed");
setDetails(data.details ?? []);
return;
}
setPreview(data);
setNames(data.packs.map((pack: { name: string }) => pack.name));
} finally {
setBusy(false);
}
}
async function saveImport() {
if (!preview || names.some((name) => !name.trim())) return;
setBusy(true);
setError("");
try {
const response = await fetch("/api/arcade/packs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
classId,
gameType,
rawJson,
...(gameType === "connections" ? { names: names.map((name) => name.trim()) } : { name: names[0]?.trim() }),
warningsAcknowledged: acknowledged,
}),
});
const data = await response.json();
if (!response.ok) {
setError(data.error ?? "Import failed");
setDetails(data.details ?? []);
return;
}
onImported();
} finally {
setBusy(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center sm:p-4">
<button className="absolute inset-0 bg-black/45 backdrop-blur-sm" onClick={onClose} aria-label="Close import dialog" />
<section role="dialog" aria-modal="true" aria-labelledby="arcade-import-title" className={`${gameType === "connections" ? "connections-world connections-import" : "crossword-world crossword-import"} relative flex max-h-[92vh] w-full max-w-2xl flex-col rounded-t-3xl border border-border-light bg-bg-surface shadow-[var(--shadow-modal)] sm:rounded-3xl`}>
<div className="flex items-center justify-between px-6 pt-5">
<h2 id="arcade-import-title" className="editorial-title text-2xl text-text-heading">Import {gameType === "connections" ? "Connections" : "Crossword"} pack</h2>
<button onClick={onClose} className="grid h-10 w-10 place-items-center rounded-xl text-xl text-text-muted hover:bg-bg-surface-alt" aria-label="Close">×</button>
</div>
<div className="flex gap-1 border-b border-border-light px-6 pt-4">
{(["generate", "import"] as const).map((item) => (
<button key={item} onClick={() => setTab(item)} className={`border-b-2 px-4 py-3 text-sm font-bold capitalize ${tab === item ? "border-primary text-primary" : "border-transparent text-text-muted"}`}>{item}</button>
))}
</div>
<div className="overflow-y-auto p-6">
{tab === "generate" ? <GenerateTab importType={gameType} /> : (
<div className="space-y-4">
<p className="text-sm text-text-secondary">{gameType === "connections" ? "Paste one pack object or an array of up to ten packs." : "Paste one Crossword pack containing exactly 80 entries."} Every board is validated before anything is saved.</p>
<textarea value={rawJson} onChange={(event) => { setRawJson(event.target.value); setPreview(null); setError(""); }} rows={11} placeholder={`Paste ${gameType === "connections" ? "Connections" : "Crossword"} JSON here…`} className="w-full resize-y rounded-xl border border-border bg-bg-surface-alt/60 px-4 py-3 font-mono text-sm text-text-heading focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" />
{!preview && <button onClick={previewImport} disabled={!rawJson.trim() || busy} className="w-full min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white disabled:opacity-45">{busy ? "Checking…" : "Preview pack(s)"}</button>}
{error && <div role="alert" className="rounded-xl border border-error/20 bg-error-bg p-4 text-sm text-error"><p className="font-bold">{error}</p>{details.length > 0 && <ul className="mt-2 list-disc space-y-1 pl-5">{details.map((detail) => <li key={detail}>{detail}</li>)}</ul>}</div>}
{preview && (
<div className="space-y-4 rounded-2xl border border-primary/15 bg-bg-callout p-4">
<div className="flex items-center justify-between"><p className="font-extrabold text-text-heading">{preview.count} {preview.count === 1 ? "pack" : "packs"} ready</p>{preview.wasRepaired && <span className="text-xs font-bold text-primary">JSON syntax repaired</span>}</div>
<div className="space-y-3">
{preview.packs.map((pack, index) => (
<article key={`${pack.name}-${index}`} className="rounded-xl border border-border-light bg-bg-surface p-3">
<label className="mb-1 block text-xs font-bold uppercase tracking-wide text-text-muted">Pack {index + 1} name</label>
<input value={names[index] ?? ""} onChange={(event) => setNames((current) => current.map((name, nameIndex) => nameIndex === index ? event.target.value : name))} className="w-full rounded-lg border border-border bg-bg-surface-alt/50 px-3 py-2 font-bold text-text-heading" />
{gameType === "connections" ? <>
<div className="mt-2 flex flex-wrap gap-1.5">{pack.categories.map((category) => <span key={category} className="rounded-full bg-bg-surface-alt px-2 py-1 text-[11px] font-bold text-text-secondary">{category}</span>)}</div>
<p className="mt-2 text-xs text-text-muted">16 tiles · 4 groups</p>
</> : <>
<p className="mt-2 text-xs text-text-muted">80 entries · four board sizes</p>
<div className="mt-2 grid grid-cols-2 gap-2 text-xs text-text-secondary">{pack.layoutPreviews?.map((layout) => <span key={layout.size} className="rounded-lg bg-bg-surface-alt px-2 py-1.5 capitalize">{layout.size}: {layout.placedCount}/{layout.targetCount}</span>)}</div>
</>}
</article>
))}
</div>
{preview.packs.some((pack) => pack.warnings.length > 0) && <div className="rounded-xl border border-amber-400/30 bg-amber-400/10 p-3 text-sm text-text-secondary"><p className="font-bold text-text-heading">Review warnings</p><ul className="mt-1 list-disc space-y-1 pl-5">{preview.packs.flatMap((pack, index) => pack.warnings.map((warning) => `Pack ${index + 1}: ${warning}`)).map((warning) => <li key={warning}>{warning}</li>)}</ul><label className="mt-3 flex items-start gap-2"><input type="checkbox" checked={acknowledged} onChange={(event) => setAcknowledged(event.target.checked)} className="mt-1" /><span>I reviewed these warnings and want to import the packs.</span></label></div>}
<button onClick={saveImport} disabled={busy || names.some((name) => !name.trim()) || (preview.packs.some((pack) => pack.warnings.length > 0) && !acknowledged)} className="w-full min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white disabled:opacity-45">{busy ? "Importing…" : `Import ${preview.count} ${preview.count === 1 ? "pack" : "packs"}`}</button>
</div>
)}
</div>
)}
</div>
</section>
</div>
);
}

View file

@ -0,0 +1,237 @@
"use client";
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { ArcadeGameShell } from "@/components/arcade/ArcadeGameShell";
import { deterministicShuffle, replayConnectionsAttempt } from "@/lib/arcade/connectionsEngine";
import type {
ArcadeRoundResult,
ArcadeSessionSettings,
ConnectionsSubmission,
NormalizedConnectionsPack,
} from "@/types/arcade";
export function ConnectionsGame({
seed,
packId,
packName,
classSlug,
pack,
settings,
}: {
seed: string;
packId: string;
packName: string;
classSlug: string;
pack: NormalizedConnectionsPack;
settings: ArcadeSessionSettings;
}) {
const allItems = useMemo(() => pack.content.flatMap((group) => group.items), [pack]);
const itemById = useMemo(() => new Map(allItems.map((item) => [item.id, item])), [allItems]);
const [tileOrder, setTileOrder] = useState(() => deterministicShuffle(allItems.map((item) => item.id), seed));
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [submissions, setSubmissions] = useState<ConnectionsSubmission[]>([]);
const [feedback, setFeedback] = useState("Select four related tiles.");
const [result, setResult] = useState<ArcadeRoundResult | null>(null);
const [finishing, setFinishing] = useState<ArcadeRoundResult | null>(null);
const [feedbackKind, setFeedbackKind] = useState<"idle" | "correct" | "wrong">("idle");
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const shuffleCount = useRef(0);
const startedAt = useRef(0);
const replay = useMemo(
() => replayConnectionsAttempt(pack, submissions, settings, elapsedSeconds),
[elapsedSeconds, pack, settings, submissions]
);
const solvedGroupIds = useMemo(
() => new Set(replay.result.groups.filter((group) => group.solved).map((group) => group.groupId)),
[replay.result.groups]
);
const solvedItemIds = useMemo(
() => new Set(pack.content.filter((group) => solvedGroupIds.has(group.id)).flatMap((group) => group.items.map((item) => item.id))),
[pack, solvedGroupIds]
);
const unresolvedOrder = tileOrder.filter((id) => !solvedItemIds.has(id));
useEffect(() => {
if (startedAt.current === 0) startedAt.current = Date.now();
if (result) return;
const interval = window.setInterval(() => setElapsedSeconds(Math.floor((Date.now() - startedAt.current) / 1000)), 1000);
return () => window.clearInterval(interval);
}, [result]);
function toggleTile(id: string) {
if (result || solvedItemIds.has(id)) return;
setSelectedIds((current) => current.includes(id) ? current.filter((itemId) => itemId !== id) : current.length < 4 ? [...current, id] : current);
}
async function persistAttempt(nextSubmissions: ConnectionsSubmission[], roundResult: ArcadeRoundResult) {
setSaveState("saving");
const response = await fetch(`/api/arcade/packs/${packId}/attempts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: "CLASSIC",
durationSeconds: roundResult.durationSeconds,
seed,
settings,
submissions: nextSubmissions,
}),
});
if (!response.ok) {
setSaveState("error");
return;
}
await response.json();
setSaveState("saved");
}
function submitSelection() {
if (selectedIds.length !== 4 || result) return;
const previousSolved = solvedGroupIds.size;
const nextSubmissions = [...submissions, { itemIds: selectedIds, elapsedMs: Date.now() - startedAt.current }];
const duration = Math.floor((Date.now() - startedAt.current) / 1000);
const nextReplay = replayConnectionsAttempt(pack, nextSubmissions, settings, duration);
setSubmissions(nextSubmissions);
setSelectedIds([]);
if (nextReplay.result.groups.filter((group) => group.solved).length > previousSolved) {
setFeedback("Correct group found.");
setFeedbackKind("correct");
} else if (nextReplay.result.hintsUsed > replay.result.hintsUsed) {
setFeedback("One away — three of those tiles belong together.");
setFeedbackKind("wrong");
} else {
setFeedback("Not a group. Try another combination.");
setFeedbackKind("wrong");
}
if (nextReplay.complete) {
setElapsedSeconds(duration);
setFinishing(nextReplay.result);
window.setTimeout(() => {
setResult(nextReplay.result);
setFinishing(null);
}, nextReplay.result.outcome === "WON" ? 1700 : 850);
void persistAttempt(nextSubmissions, nextReplay.result);
}
}
function shuffleTiles() {
shuffleCount.current += 1;
const shuffled = deterministicShuffle(unresolvedOrder, `${seed}-shuffle-${shuffleCount.current}`);
setTileOrder([...tileOrder.filter((id) => solvedItemIds.has(id)), ...shuffled]);
setSelectedIds([]);
setFeedback("Unsolved tiles shuffled.");
setFeedbackKind("idle");
}
function handleTileKey(event: React.KeyboardEvent<HTMLButtonElement>, id: string) {
const moves: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -4, ArrowDown: 4 };
const move = moves[event.key];
if (!move) return;
event.preventDefault();
const index = unresolvedOrder.indexOf(id);
const next = unresolvedOrder[(index + move + unresolvedOrder.length) % unresolvedOrder.length];
document.querySelector<HTMLButtonElement>(`[data-arcade-tile="${next}"]`)?.focus();
}
if (result) {
return (
<ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/connections`} elapsedSeconds={elapsedSeconds} complete>
<ConnectionsResults result={result} itemById={itemById} saveState={saveState} onReplay={() => window.location.reload()} exitHref={`/${classSlug}/arcade/connections`} />
</ArcadeGameShell>
);
}
if (finishing) {
return (
<ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/connections`} elapsedSeconds={elapsedSeconds} complete>
<CompletionTransition result={finishing} />
</ArcadeGameShell>
);
}
return (
<ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/connections`} elapsedSeconds={elapsedSeconds} complete={false}>
<div className="connections-status mb-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-border-light bg-bg-surface p-4">
<div><p className="text-xs font-bold uppercase tracking-wide text-text-muted">Mistakes remaining</p><div className="mt-1 flex gap-1.5" aria-label={`${settings.allowedMistakes - replay.result.mistakes} mistakes remaining`}>{Array.from({ length: settings.allowedMistakes }, (_, index) => <span key={index} className={`h-3 w-3 rounded-full border ${index < settings.allowedMistakes - replay.result.mistakes ? "border-primary bg-primary" : "border-border bg-bg-surface-alt"}`} />)}</div></div>
<p className="text-sm font-bold text-text-secondary" aria-live="polite">{feedback}</p>
</div>
{pack.content.filter((group) => solvedGroupIds.has(group.id)).map((group, index) => (
<div key={group.id} className={`connections-group connections-group-${index % 4} mb-2 rounded-2xl p-4 text-center`}>
<h2 className="font-extrabold uppercase tracking-wide text-text-heading">{group.category}</h2>
<p className="mt-1 text-sm font-semibold text-text-secondary">{group.items.map((item) => item.text).join(" · ")}</p>
</div>
))}
<div key={`board-${submissions.length}`} className={`connections-board grid grid-cols-4 gap-2 sm:gap-3 ${feedbackKind === "wrong" ? "animate-connections-shake" : ""}`} role="group" aria-label="Connections tiles">
{unresolvedOrder.map((id) => {
const item = itemById.get(id);
if (!item) return null;
const selected = selectedIds.includes(id);
return <button key={id} data-arcade-tile={id} aria-pressed={selected} onKeyDown={(event) => handleTileKey(event, id)} onClick={() => toggleTile(id)} className={`connections-tile min-h-20 break-words rounded-xl px-1.5 py-2 text-[11px] font-extrabold leading-tight sm:min-h-24 sm:px-3 sm:text-sm ${selected ? "is-selected" : ""}`}>{item.text}</button>;
})}
</div>
<div className="mt-5 grid grid-cols-2 gap-3 sm:flex sm:justify-center">
<button onClick={shuffleTiles} className="connections-secondary-button min-h-11 rounded-xl px-5 text-sm font-bold">Shuffle</button>
<button onClick={() => setSelectedIds([])} disabled={selectedIds.length === 0} className="connections-secondary-button min-h-11 rounded-xl px-5 text-sm font-bold disabled:opacity-40">Clear</button>
<button onClick={submitSelection} disabled={selectedIds.length !== 4} className="connections-primary-button col-span-2 min-h-11 rounded-xl px-7 text-sm font-extrabold disabled:opacity-40 sm:col-span-1">Submit group ({selectedIds.length}/4)</button>
</div>
</ArcadeGameShell>
);
}
function ConnectionsResults({
result,
itemById,
saveState,
onReplay,
exitHref,
}: {
result: ArcadeRoundResult;
itemById: Map<string, { id: string; text: string }>;
saveState: "idle" | "saving" | "saved" | "error";
onReplay: () => void;
exitHref: string;
}) {
const orderedGroups = [...result.groups].sort((a, b) => Number(a.solved) - Number(b.solved));
return (
<div className="animate-connections-results-in">
<section className="connections-results-hero rounded-3xl p-6 text-white sm:p-8">
<p className="text-xs font-bold uppercase tracking-[0.18em] text-white/55">Round complete</p>
<h2 className="editorial-title mt-1 text-4xl text-white">{result.outcome === "WON" ? "Board cleared" : "Groups revealed"}</h2>
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">{[["Score", `${result.score}/${result.maxScore}`], ["Accuracy", `${Math.round(result.accuracy * 100)}%`], ["Mistakes", result.mistakes], ["Time", `${result.durationSeconds}s`]].map(([label, value]) => <div key={label} className="rounded-2xl bg-white/8 p-3"><p className="text-xs font-bold uppercase text-white/45">{label}</p><p className="mt-1 text-xl font-extrabold">{value}</p></div>)}</div>
<p className="mt-4 text-xs text-white/55">{saveState === "saving" ? "Saving attempt…" : saveState === "error" ? "Attempt could not be saved." : "Attempt saved."}</p>
</section>
<div className="mt-5 space-y-3">{orderedGroups.map((group, index) => <article key={group.groupId} className={`connections-review-card connections-group-${index % 4} rounded-2xl p-4`}><p className="text-xs font-extrabold uppercase tracking-wide opacity-60">{group.solved ? "Solved" : "Revealed"}</p><h3 className="mt-1 text-lg font-extrabold">{group.category}</h3><p className="mt-1 text-sm font-semibold opacity-75">{group.items.join(" · ")}</p><p className="mt-3 text-sm leading-6 opacity-80">{group.explanation}</p></article>)}</div>
{result.incorrectSelections.length > 0 && <section className="mt-5 rounded-2xl border border-border-light bg-bg-surface p-4"><h3 className="font-extrabold text-text-heading">Incorrect selections</h3><ul className="mt-2 space-y-1 text-sm text-text-secondary">{result.incorrectSelections.map((selection, index) => <li key={`${selection.join("-")}-${index}`}>{selection.map((id) => itemById.get(id)?.text ?? id).join(" · ")}</li>)}</ul></section>}
<div className="mt-6 grid grid-cols-2 gap-3"><button onClick={onReplay} className="min-h-12 rounded-xl bg-primary px-5 text-sm font-extrabold text-white">Play again</button><a href={exitHref} className="flex min-h-12 items-center justify-center rounded-xl border border-border bg-bg-surface px-5 text-sm font-extrabold text-text-heading">Back to packs</a></div>
</div>
);
}
function CompletionTransition({ result }: { result: ArcadeRoundResult }) {
const won = result.outcome === "WON";
return (
<div className={`connections-completion ${won ? "is-win" : "is-loss"}`}>
{won && <ConfettiBurst />}
<div className="connections-completion-mark" aria-hidden>{won ? "✓" : "!"}</div>
<p>{won ? "Perfect connection" : "Round complete"}</p>
<h2>{won ? "Board cleared!" : "Groups revealed"}</h2>
<span>{won ? `${result.score} points` : "Lets review the board"}</span>
</div>
);
}
function ConfettiBurst() {
const colors = ["#ffd166", "#ef476f", "#06d6a0", "#4cc9f0", "#a78bfa"];
return <div className="connections-confetti" aria-hidden>{Array.from({ length: 52 }, (_, index) => {
const style = {
"--confetti-x": `${(index * 37) % 100}%`,
"--confetti-delay": `${(index % 13) * 0.035}s`,
"--confetti-spin": `${180 + (index % 7) * 80}deg`,
"--confetti-color": colors[index % colors.length],
} as CSSProperties;
return <i key={index} style={style} />;
})}</div>;
}

View file

@ -0,0 +1,123 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { ArcadeImportModal } from "@/components/arcade/ArcadeImportModal";
import type { ArcadeAttemptSummary, ArcadePackSummary } from "@/types/arcade";
export function ConnectionsHub({
classId,
classSlug,
initialPacks,
}: {
classId: string;
classSlug: string;
initialPacks: ArcadePackSummary[];
}) {
const router = useRouter();
const [selectedId, setSelectedId] = useState(initialPacks[0]?.id ?? "");
const [showImport, setShowImport] = useState(false);
const [allowedMistakes, setAllowedMistakes] = useState(initialPacks[0]?.defaultAllowedMistakes ?? 4);
const [oneAwayFeedback, setOneAwayFeedback] = useState(true);
const [attempts, setAttempts] = useState<ArcadeAttemptSummary[]>([]);
const [attemptsLoading, setAttemptsLoading] = useState(initialPacks.length > 0);
const effectiveSelectedId = initialPacks.some((pack) => pack.id === selectedId)
? selectedId
: initialPacks[0]?.id ?? "";
const selected = useMemo(() => initialPacks.find((pack) => pack.id === effectiveSelectedId) ?? null, [effectiveSelectedId, initialPacks]);
useEffect(() => {
if (!selected) return;
fetch(`/api/arcade/packs/${selected.id}/attempts`)
.then((response) => response.ok ? response.json() : [])
.then(setAttempts)
.finally(() => setAttemptsLoading(false));
}, [selected]);
function selectPack(pack: ArcadePackSummary) {
setSelectedId(pack.id);
setAllowedMistakes(pack.defaultAllowedMistakes);
setAttempts([]);
setAttemptsLoading(true);
}
async function renamePack(pack: ArcadePackSummary) {
const name = window.prompt("Rename Connections pack", pack.name)?.trim();
if (!name || name === pack.name) return;
await fetch(`/api/arcade/packs/${pack.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
router.refresh();
}
async function deletePack(pack: ArcadePackSummary) {
if (!window.confirm(`Delete “${pack.name}” and its attempt history?`)) return;
await fetch(`/api/arcade/packs/${pack.id}`, { method: "DELETE" });
if (selectedId === pack.id) setSelectedId("");
router.refresh();
}
const playHref = selected
? `/${classSlug}/arcade/connections/${selected.id}/play?mistakes=${allowedMistakes}&oneAway=${oneAwayFeedback ? "1" : "0"}`
: "#";
return (
<div className="connections-world connections-hub p-1 pb-20 sm:p-3">
<div className="connections-hub-hero mb-7 flex flex-col gap-4 rounded-3xl p-5 sm:flex-row sm:items-end sm:justify-between sm:p-7">
<div>
<Link href={`/${classSlug}/arcade`} className="mb-3 inline-flex min-h-10 items-center text-sm font-bold text-text-muted hover:text-primary"> Back to Arcade</Link>
<p className="text-xs font-bold uppercase tracking-[0.18em] text-primary">The pattern room</p>
<h2 className="editorial-title mt-1 text-4xl text-text-heading">Connections</h2>
<p className="mt-2 max-w-xl text-sm leading-6 text-text-secondary">Choose a pack, tune the mistake limit, and find the four hidden groups.</p>
</div>
<button onClick={() => setShowImport(true)} className="connections-primary-button min-h-12 rounded-xl px-5 text-sm font-bold">Import pack</button>
</div>
{initialPacks.length === 0 ? (
<div className="rounded-3xl border border-dashed border-border bg-bg-surface px-6 py-16 text-center">
<div className="mx-auto mb-4 grid w-fit grid-cols-2 gap-1.5" aria-hidden>{["bg-amber-400", "bg-emerald-500", "bg-sky-500", "bg-violet-500"].map((color) => <span key={color} className={`h-8 w-8 rounded-lg ${color}`} />)}</div>
<h3 className="editorial-title text-2xl text-text-heading">Import your first board</h3>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-text-secondary">Connections uses purpose-built packs with four groups of four terms.</p>
<button onClick={() => setShowImport(true)} className="mt-6 min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white">Import Connections JSON</button>
</div>
) : (
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.15fr)_minmax(20rem,.85fr)]">
<section>
<h3 className="mb-3 text-sm font-extrabold uppercase tracking-[0.14em] text-text-muted">Study packs</h3>
<div className="space-y-3">
{initialPacks.map((pack) => {
const active = pack.id === effectiveSelectedId;
return (
<article key={pack.id} className={`connections-pack-card rounded-2xl border p-4 transition-all ${active ? "is-active" : ""}`}>
<button onClick={() => selectPack(pack)} className="w-full text-left">
<div className="flex items-start justify-between gap-3"><div><h4 className="text-lg font-extrabold text-text-heading">{pack.name}</h4>{pack.description && <p className="mt-1 text-sm leading-5 text-text-secondary">{pack.description}</p>}</div><span className={`mt-1 h-4 w-4 shrink-0 rounded-full border-4 ${active ? "border-primary bg-white" : "border-border bg-bg-surface"}`} aria-hidden /></div>
<div className="mt-3 flex flex-wrap gap-2 text-xs font-bold text-text-muted"><span className="rounded-full bg-bg-surface-alt px-2.5 py-1">16 tiles</span><span className="rounded-full bg-bg-surface-alt px-2.5 py-1">Best {pack.bestScore ?? "—"}/400</span><span className="rounded-full bg-bg-surface-alt px-2.5 py-1">Latest {pack.latestAttempt?.score ?? "—"}</span></div>
</button>
<div className="mt-3 flex justify-end gap-2 border-t border-border-light pt-3"><button onClick={() => renamePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-bg-surface-alt hover:text-text-heading">Rename</button><button onClick={() => deletePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-error-bg hover:text-error">Delete</button></div>
</article>
);
})}
</div>
</section>
<aside className="connections-setup h-fit rounded-3xl border border-border-light bg-bg-surface p-5 lg:sticky lg:top-6">
<h3 className="editorial-title text-2xl text-text-heading">Round setup</h3>
{selected ? <>
<p className="mt-1 text-sm font-bold text-primary">{selected.name}</p>
<label className="mt-5 block text-sm font-bold text-text-heading">Allowed mistakes <span className="text-primary">{allowedMistakes}</span></label>
<input type="range" min={1} max={8} value={allowedMistakes} onChange={(event) => setAllowedMistakes(Number(event.target.value))} className="mt-2 w-full accent-primary" />
<label className="mt-5 flex items-start gap-3 rounded-xl bg-bg-surface-alt p-3 text-sm text-text-secondary"><input type="checkbox" checked={oneAwayFeedback} onChange={(event) => setOneAwayFeedback(event.target.checked)} className="mt-1" /><span><strong className="block text-text-heading">One-away feedback</strong>Tell me when three selected tiles belong together.</span></label>
<div className="mt-5 rounded-xl border border-border-light p-3 text-sm text-text-secondary"><div className="flex justify-between"><span>Board</span><strong className="text-text-heading">4 × 4</strong></div><div className="mt-2 flex justify-between"><span>Possible score</span><strong className="text-text-heading">400</strong></div></div>
<Link href={playHref} className="connections-primary-button mt-5 flex min-h-12 items-center justify-center rounded-xl px-5 text-sm font-extrabold">Enter the board</Link>
<div className="mt-7 border-t border-border-light pt-5"><h4 className="text-sm font-extrabold uppercase tracking-wide text-text-muted">Recent attempts</h4>{attemptsLoading ? <p className="mt-3 text-sm text-text-muted">Loading history</p> : attempts.length === 0 ? <p className="mt-3 text-sm text-text-muted">No attempts yet.</p> : <div className="mt-3 space-y-2">{attempts.slice(0, 5).map((attempt) => <div key={attempt.id} className="flex items-center justify-between rounded-xl bg-bg-surface-alt px-3 py-2 text-sm"><span className="font-bold text-text-heading">{attempt.score}/400</span><span className="text-text-muted">{Math.round(attempt.accuracy * 100)}% · {attempt.durationSeconds}s</span></div>)}</div>}</div>
</> : <p className="mt-3 text-sm text-text-muted">Select a pack to configure the round.</p>}
</aside>
</div>
)}
{showImport && <ArcadeImportModal classId={classId} gameType="connections" onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); router.refresh(); }} />}
</div>
);
}

View file

@ -0,0 +1,269 @@
"use client";
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { ArcadeGameShell } from "@/components/arcade/ArcadeGameShell";
import type {
CrosswordAction,
CrosswordLayout,
CrosswordPlacedEntry,
CrosswordRoundResult,
CrosswordSessionSettings,
NormalizedCrosswordPack,
} from "@/types/arcade";
function entryValue(entry: CrosswordPlacedEntry, answers: Record<string, string>) {
return entry.cellKeys.map((cellKey) => answers[cellKey] ?? "").join("");
}
export function CrosswordGame({ seed, packId, packName, classSlug, layout, settings }: {
seed: string;
packId: string;
packName: string;
classSlug: string;
pack: NormalizedCrosswordPack;
layout: CrosswordLayout;
settings: CrosswordSessionSettings;
}) {
const orderedEntries = useMemo(() => [...layout.entries].sort((a, b) => a.number - b.number || a.direction.localeCompare(b.direction)), [layout.entries]);
const entryById = useMemo(() => new Map(orderedEntries.map((entry) => [entry.id, entry])), [orderedEntries]);
const cellByKey = useMemo(() => new Map(layout.cells.map((cell) => [cell.key, cell])), [layout.cells]);
const [answers, setAnswers] = useState<Record<string, string>>({});
const [activeEntryId, setActiveEntryId] = useState(orderedEntries[0]?.id ?? "");
const [activeCellKey, setActiveCellKey] = useState(orderedEntries[0]?.cellKeys[0] ?? "");
const [clueTab, setClueTab] = useState<"across" | "down">(orderedEntries[0]?.direction ?? "across");
const [actions, setActions] = useState<CrosswordAction[]>([]);
const [checkedWrong, setCheckedWrong] = useState<Set<string>>(new Set());
const [revealedCells, setRevealedCells] = useState<Set<string>>(new Set());
const [alternateClues, setAlternateClues] = useState<Set<string>>(new Set());
const [feedback, setFeedback] = useState(`${layout.entries.length} of ${layout.targetCount} target words placed.`);
const [zoom, setZoom] = useState(1);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [result, setResult] = useState<CrosswordRoundResult | null>(null);
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
const startedAt = useRef(0);
const mobileInput = useRef<HTMLInputElement>(null);
const activeEntry = entryById.get(activeEntryId) ?? orderedEntries[0];
useEffect(() => {
if (startedAt.current === 0) startedAt.current = Date.now();
if (result) return;
const timer = window.setInterval(() => setElapsedSeconds(Math.floor((Date.now() - startedAt.current) / 1000)), 1000);
return () => window.clearInterval(timer);
}, [result]);
function selectEntry(entry: CrosswordPlacedEntry, cellKey = entry.cellKeys[0]) {
setActiveEntryId(entry.id);
setActiveCellKey(cellKey);
setClueTab(entry.direction);
}
function selectCell(cellKey: string) {
const cell = cellByKey.get(cellKey);
if (!cell) return;
let nextId = cell.entryIds[0];
if (cell.entryIds.includes(activeEntryId) && cell.entryIds.length > 1 && activeCellKey === cellKey) {
nextId = cell.entryIds.find((id) => id !== activeEntryId) ?? nextId;
} else if (!cell.entryIds.includes(activeEntryId)) {
nextId = cell.entryIds.find((id) => entryById.get(id)?.direction === clueTab) ?? nextId;
} else {
nextId = activeEntryId;
}
const entry = entryById.get(nextId);
if (entry) selectEntry(entry, cellKey);
mobileInput.current?.focus({ preventScroll: true });
}
function moveWithinEntry(offset: number) {
if (!activeEntry) return;
const index = Math.max(0, activeEntry.cellKeys.indexOf(activeCellKey));
setActiveCellKey(activeEntry.cellKeys[Math.max(0, Math.min(activeEntry.cellKeys.length - 1, index + offset))]);
}
function writeLetter(letter: string) {
if (!activeEntry || !activeCellKey || result || revealedCells.has(activeCellKey)) return;
const upper = letter.replace(/[^A-Za-z]/g, "").slice(-1).toUpperCase();
if (!upper) return;
const nextAnswers = { ...answers, [activeCellKey]: upper };
setAnswers(nextAnswers);
if (settings.instantCheck) {
const value = activeEntry.cellKeys.map((cellKey) => nextAnswers[cellKey] ?? "").join("");
if (value.length === activeEntry.answer.length) {
setCheckedWrong((checked) => {
const updated = new Set(checked);
activeEntry.cellKeys.forEach((cellKey, index) => {
if ((nextAnswers[cellKey] ?? "") === activeEntry.answer[index]) updated.delete(cellKey);
else updated.add(cellKey);
});
return updated;
});
}
}
setCheckedWrong((current) => { const next = new Set(current); next.delete(activeCellKey); return next; });
moveWithinEntry(1);
}
function clearLetter() {
if (!activeCellKey || revealedCells.has(activeCellKey)) return;
if (answers[activeCellKey]) {
setAnswers((current) => { const next = { ...current }; delete next[activeCellKey]; return next; });
setCheckedWrong((current) => { const next = new Set(current); next.delete(activeCellKey); return next; });
} else {
moveWithinEntry(-1);
}
}
function cycleEntry(offset: number) {
const index = Math.max(0, orderedEntries.findIndex((entry) => entry.id === activeEntryId));
const entry = orderedEntries[(index + offset + orderedEntries.length) % orderedEntries.length];
selectEntry(entry);
}
function handleKeyDown(event: React.KeyboardEvent) {
if (/^[a-zA-Z]$/.test(event.key)) { event.preventDefault(); writeLetter(event.key); return; }
if (event.key === "Backspace" || event.key === "Delete") { event.preventDefault(); clearLetter(); return; }
if (event.key === "Tab") { event.preventDefault(); cycleEntry(event.shiftKey ? -1 : 1); return; }
if (event.key === "Enter") { event.preventDefault(); checkWord(); return; }
const cell = cellByKey.get(activeCellKey);
if (!cell || !event.key.startsWith("Arrow")) return;
event.preventDefault();
const delta = event.key === "ArrowLeft" ? [0, -1] : event.key === "ArrowRight" ? [0, 1] : event.key === "ArrowUp" ? [-1, 0] : [1, 0];
const next = cellByKey.get(`${cell.row + delta[0]}:${cell.column + delta[1]}`);
if (next) selectCell(next.key);
}
function incorrectCells(entry: CrosswordPlacedEntry) {
return entry.cellKeys.filter((cellKey, index) => (answers[cellKey] ?? "") !== entry.answer[index]);
}
function checkWord() {
if (!activeEntry) return;
const wrong = incorrectCells(activeEntry);
setCheckedWrong((current) => new Set([...current, ...wrong]));
setActions((current) => [...current, { type: "CHECK_WORD", entryId: activeEntry.id, value: entryValue(activeEntry, answers), elapsedMs: Date.now() - startedAt.current }]);
setFeedback(wrong.length === 0 ? `${activeEntry.number} ${activeEntry.direction} is correct.` : `${wrong.length} cell${wrong.length === 1 ? " is" : "s are"} incorrect in this word.`);
}
function checkPuzzle() {
const wrong = orderedEntries.flatMap(incorrectCells);
setCheckedWrong(new Set(wrong));
setActions((current) => [...current, { type: "CHECK_PUZZLE", answers: Object.fromEntries(orderedEntries.map((entry) => [entry.id, entryValue(entry, answers)])), elapsedMs: Date.now() - startedAt.current }]);
setFeedback(wrong.length === 0 ? "Every filled answer is correct." : `${wrong.length} cells need another look.`);
}
function showAlternateClue() {
if (!activeEntry || !settings.allowHints) return;
setAlternateClues((current) => new Set(current).add(activeEntry.id));
setActions((current) => [...current, { type: "ALTERNATE_CLUE", entryId: activeEntry.id, elapsedMs: Date.now() - startedAt.current }]);
}
function revealLetter() {
if (!activeEntry || !activeCellKey || !settings.allowHints) return;
const index = activeEntry.cellKeys.indexOf(activeCellKey);
setAnswers((current) => ({ ...current, [activeCellKey]: activeEntry.answer[index] }));
setRevealedCells((current) => new Set(current).add(activeCellKey));
setCheckedWrong((current) => { const next = new Set(current); next.delete(activeCellKey); return next; });
setActions((current) => [...current, { type: "REVEAL_LETTER", cellKey: activeCellKey, elapsedMs: Date.now() - startedAt.current }]);
moveWithinEntry(1);
}
function revealWord() {
if (!activeEntry || !settings.allowHints) return;
setAnswers((current) => ({ ...current, ...Object.fromEntries(activeEntry.cellKeys.map((cellKey, index) => [cellKey, activeEntry.answer[index]])) }));
setRevealedCells((current) => new Set([...current, ...activeEntry.cellKeys]));
setCheckedWrong((current) => { const next = new Set(current); activeEntry.cellKeys.forEach((cellKey) => next.delete(cellKey)); return next; });
setActions((current) => [...current, { type: "REVEAL_WORD", entryId: activeEntry.id, elapsedMs: Date.now() - startedAt.current }]);
setFeedback(`${activeEntry.number} ${activeEntry.direction} was revealed.`);
}
async function finish(gaveUp: boolean) {
if (saveState === "saving") return;
if (!gaveUp && !window.confirm("Submit this puzzle and reveal the results?")) return;
if (gaveUp && !window.confirm("Give up and reveal every remaining answer?")) return;
setSaveState("saving");
const durationSeconds = Math.floor((Date.now() - startedAt.current) / 1000);
const response = await fetch(`/api/arcade/packs/${packId}/attempts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: "CROSSWORD",
durationSeconds,
seed,
settings,
finalAnswers: Object.fromEntries(orderedEntries.map((entry) => [entry.id, entryValue(entry, answers)])),
actions,
gaveUp,
}),
});
const data = await response.json();
if (!response.ok || !data.results) { setSaveState("error"); setFeedback(data.error ?? "Attempt could not be saved."); return; }
setElapsedSeconds(durationSeconds);
setResult(data.results);
}
if (result) {
return <ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/crossword`} elapsedSeconds={elapsedSeconds} complete worldClassName="crossword-world crossword-stage"><CrosswordResults result={result} layout={layout} answers={answers} revealedCells={revealedCells} exitHref={`/${classSlug}/arcade/crossword`} /></ArcadeGameShell>;
}
const activeKeys = new Set(activeEntry?.cellKeys ?? []);
const visibleClues = orderedEntries.filter((entry) => entry.direction === clueTab);
const cellSize = Math.round(34 * zoom);
return (
<ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/crossword`} elapsedSeconds={elapsedSeconds} complete={false} worldClassName="crossword-world crossword-stage">
<input ref={mobileInput} value="" onKeyDown={handleKeyDown} onChange={(event) => writeLetter(event.target.value)} className="crossword-mobile-input" aria-label="Type crossword letter" autoCapitalize="characters" inputMode="text" />
<section className="crossword-active-clue mb-4 rounded-2xl p-4" aria-live="polite"><span>{activeEntry?.number} {activeEntry?.direction}</span><strong>{alternateClues.has(activeEntry?.id ?? "") ? activeEntry?.alternateClue : activeEntry?.clue}</strong><small>{feedback}</small></section>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_22rem]">
<div>
<div className="crossword-toolbar mb-3 flex flex-wrap items-center justify-between gap-2 rounded-xl p-2"><div className="flex gap-1"><button onClick={() => setZoom((value) => Math.max(.7, value - .1))} aria-label="Zoom out"></button><span>{Math.round(zoom * 100)}%</span><button onClick={() => setZoom((value) => Math.min(1.5, value + .1))} aria-label="Zoom in">+</button></div><span>{layout.entries.length} placed · {layout.omittedEntries.length} omitted</span></div>
<div className="crossword-board-viewport rounded-2xl" onKeyDown={handleKeyDown} tabIndex={0} aria-label="Crossword grid">
<div className="crossword-board" style={{ "--crossword-cell": `${cellSize}px`, gridTemplateColumns: `repeat(${layout.columns}, var(--crossword-cell))`, gridTemplateRows: `repeat(${layout.rows}, var(--crossword-cell))` } as CSSProperties}>
{layout.cells.map((cell) => <button key={cell.key} onClick={() => selectCell(cell.key)} aria-label={`Row ${cell.row + 1}, column ${cell.column + 1}${answers[cell.key] ? `, ${answers[cell.key]}` : ""}`} className={`crossword-cell ${activeKeys.has(cell.key) ? "is-word" : ""} ${activeCellKey === cell.key ? "is-active" : ""} ${checkedWrong.has(cell.key) ? "is-wrong" : ""} ${revealedCells.has(cell.key) ? "is-revealed" : ""}`} style={{ gridRow: cell.row + 1, gridColumn: cell.column + 1 }}><small>{cell.number}</small><strong>{answers[cell.key] ?? ""}</strong></button>)}
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
<button onClick={checkWord} className="crossword-secondary">Check word</button><button onClick={checkPuzzle} className="crossword-secondary">Check puzzle</button>
{settings.allowHints && <><button onClick={showAlternateClue} className="crossword-secondary">Alternate clue</button><button onClick={revealLetter} className="crossword-secondary">Reveal letter</button><button onClick={revealWord} className="crossword-secondary">Reveal word</button></>}
</div>
<div className="mt-4 grid grid-cols-2 gap-3"><button onClick={() => void finish(true)} className="crossword-secondary min-h-12">Give up</button><button onClick={() => void finish(false)} className="crossword-primary min-h-12 rounded-xl font-extrabold">Submit puzzle</button></div>
{saveState === "error" && <p className="mt-2 text-sm text-error" role="alert">The attempt could not be saved. Your puzzle is still open.</p>}
</div>
<aside className="crossword-clues rounded-2xl p-4">
<div className="grid grid-cols-2 gap-2">{(["across", "down"] as const).map((tab) => <button key={tab} aria-pressed={clueTab === tab} onClick={() => setClueTab(tab)} className={clueTab === tab ? "is-active" : ""}>{tab}</button>)}</div>
<ol className="mt-4 space-y-2">{visibleClues.map((entry) => <li key={entry.id}><button onClick={() => selectEntry(entry)} className={entry.id === activeEntryId ? "is-active" : ""}><b>{entry.number}</b><span>{alternateClues.has(entry.id) ? entry.alternateClue : entry.clue}</span></button></li>)}</ol>
</aside>
</div>
</ArcadeGameShell>
);
}
function CrosswordResults({ result, layout, answers, revealedCells, exitHref }: { result: CrosswordRoundResult; layout: CrosswordLayout; answers: Record<string, string>; revealedCells: Set<string>; exitHref: string }) {
const ordered = result.entries.filter((entry) => !entry.omitted).sort((left, right) => {
const rank = (entry: typeof left) => !entry.correct ? 0 : entry.revealedWord || entry.revealedLetters > 0 || entry.alternateClueUsed ? 1 : 2;
return rank(left) - rank(right);
});
return <div className="crossword-results">
<section className="crossword-results-hero rounded-3xl p-6 sm:p-8"><p className="crossword-kicker">Final edition</p><h2 className="editorial-title mt-1 text-4xl">{result.outcome === "GAVE_UP" ? "Answers revealed" : "Puzzle submitted"}</h2><div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">{[["Score", `${result.score}/${result.maxScore}`], ["Accuracy", `${Math.round(result.accuracy * 100)}%`], ["Hints", result.hintsUsed], ["Words", result.placedCount]].map(([label, value]) => <div key={label} className="rounded-xl p-3"><small>{label}</small><strong>{value}</strong></div>)}</div></section>
<FinalCrosswordBoard layout={layout} answers={answers} revealedCells={revealedCells} />
<div className="mt-5 space-y-3">{ordered.map((entry) => {
const assisted = entry.revealedWord || entry.revealedLetters > 0;
return <article key={entry.entryId} className={`crossword-review rounded-2xl p-4 ${assisted ? "is-assisted" : entry.correct ? "is-correct" : "is-incorrect"}`}><p className="text-xs font-extrabold uppercase tracking-wide">{assisted ? "Assisted" : entry.correct ? "Correct" : "Incorrect"}</p><h3 className="mt-1 text-lg font-extrabold">{entry.answer}</h3><p className="mt-1 text-sm"><strong>Clue:</strong> {entry.clue}</p><p className="mt-1 text-sm"><strong>Your answer:</strong> {entry.playerAnswer || "No answer"}</p><p className="mt-3 text-sm leading-6">{entry.explanation}</p></article>;
})}</div>
<div className="mt-6 grid grid-cols-2 gap-3"><button onClick={() => window.location.reload()} className="crossword-primary min-h-12 rounded-xl font-extrabold">New layout</button><a href={exitHref} className="crossword-secondary flex min-h-12 items-center justify-center">Back to banks</a></div>
</div>;
}
function FinalCrosswordBoard({ layout, answers, revealedCells }: { layout: CrosswordLayout; answers: Record<string, string>; revealedCells: Set<string> }) {
const cellSize = Math.max(18, Math.min(30, Math.floor(640 / Math.max(layout.rows, layout.columns))));
return <section className="crossword-final-board mt-5 rounded-3xl p-4 sm:p-6">
<div className="flex flex-wrap items-end justify-between gap-3"><div><p className="crossword-kicker">Completed grid</p><h3 className="editorial-title mt-1 text-2xl">Your final board</h3></div><div className="crossword-final-legend"><span><i className="is-correct" />Correct</span><span><i className="is-assisted" />Revealed or assisted</span><span><i className="is-incorrect" />Incorrect or blank</span></div></div>
<div className="crossword-final-viewport mt-4">
<div className="crossword-final-grid" style={{ gridTemplateColumns: `repeat(${layout.columns}, ${cellSize}px)`, gridTemplateRows: `repeat(${layout.rows}, ${cellSize}px)` }}>
{layout.cells.map((cell) => {
const assisted = revealedCells.has(cell.key);
const correct = answers[cell.key] === cell.answer;
const state = assisted ? "assisted" : correct ? "correct" : "incorrect";
return <div key={cell.key} className={`crossword-final-cell is-${state}`} style={{ gridColumn: cell.column + 1, gridRow: cell.row + 1 }} aria-label={`${cell.answer}, ${state === "incorrect" ? "incorrect or blank" : state}`}><small>{cell.number}</small><strong>{cell.answer}</strong></div>;
})}
</div>
</div>
</section>;
}

View file

@ -0,0 +1,152 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { ArcadeImportModal } from "@/components/arcade/ArcadeImportModal";
import { CROSSWORD_SIZE_TARGETS } from "@/lib/arcade/crosswordEngine";
import type { ArcadeAttemptSummary, ArcadePackSummary, CrosswordLayout, CrosswordSize } from "@/types/arcade";
const SIZES: { value: CrosswordSize; label: string; note: string }[] = [
{ value: "mini", label: "Mini", note: "Up to 15 words" },
{ value: "standard", label: "Standard", note: "Up to 30 words" },
{ value: "large", label: "Large", note: "Up to 50 words" },
{ value: "extra-large", label: "Extra Large", note: "Up to 80 words" },
];
export function CrosswordHub({ classId, classSlug, initialPacks }: { classId: string; classSlug: string; initialPacks: ArcadePackSummary[] }) {
const router = useRouter();
const [selectedId, setSelectedId] = useState(initialPacks[0]?.id ?? "");
const [showImport, setShowImport] = useState(false);
const [size, setSize] = useState<CrosswordSize>("standard");
const [instantCheck, setInstantCheck] = useState(initialPacks[0]?.defaultInstantCheck ?? false);
const [allowHints, setAllowHints] = useState(initialPacks[0]?.defaultAllowHints ?? true);
const [attempts, setAttempts] = useState<ArcadeAttemptSummary[]>([]);
const [attemptsLoading, setAttemptsLoading] = useState(initialPacks.length > 0);
const [previewLayout, setPreviewLayout] = useState<CrosswordLayout | null>(null);
const [previewLoading, setPreviewLoading] = useState(initialPacks.length > 0);
const effectiveSelectedId = initialPacks.some((pack) => pack.id === selectedId) ? selectedId : initialPacks[0]?.id ?? "";
const selected = useMemo(() => initialPacks.find((pack) => pack.id === effectiveSelectedId) ?? null, [effectiveSelectedId, initialPacks]);
useEffect(() => {
if (!selected) return;
fetch(`/api/arcade/packs/${selected.id}/attempts`)
.then((response) => response.ok ? response.json() : [])
.then(setAttempts)
.finally(() => setAttemptsLoading(false));
}, [selected]);
useEffect(() => {
if (!selected) return;
let current = true;
fetch(`/api/arcade/packs/${selected.id}/layout?size=${size}`)
.then((response) => response.ok ? response.json() : null)
.then((layout) => { if (current) setPreviewLayout(layout); })
.finally(() => { if (current) setPreviewLoading(false); });
return () => { current = false; };
}, [selected, size]);
function selectPack(pack: ArcadePackSummary) {
if (pack.id === effectiveSelectedId) return;
setSelectedId(pack.id);
setInstantCheck(pack.defaultInstantCheck);
setAllowHints(pack.defaultAllowHints);
setAttempts([]);
setAttemptsLoading(true);
setPreviewLayout(null);
setPreviewLoading(true);
}
function selectSize(nextSize: CrosswordSize) {
if (nextSize === size) return;
setSize(nextSize);
setPreviewLayout(null);
setPreviewLoading(true);
}
async function renamePack(pack: ArcadePackSummary) {
const name = window.prompt("Rename Crossword pack", pack.name)?.trim();
if (!name || name === pack.name) return;
await fetch(`/api/arcade/packs/${pack.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) });
router.refresh();
}
async function deletePack(pack: ArcadePackSummary) {
if (!window.confirm(`Delete “${pack.name}” and its attempt history?`)) return;
await fetch(`/api/arcade/packs/${pack.id}`, { method: "DELETE" });
if (selectedId === pack.id) setSelectedId("");
router.refresh();
}
const playHref = selected
? `/${classSlug}/arcade/crossword/${selected.id}/play?size=${size}&instant=${instantCheck ? "1" : "0"}&hints=${allowHints ? "1" : "0"}`
: "#";
return (
<div className="crossword-world crossword-hub p-1 pb-20 sm:p-3">
<section className="crossword-hero mb-7 rounded-3xl p-5 sm:flex sm:items-end sm:justify-between sm:p-7">
<div>
<Link href={`/${classSlug}/arcade`} className="mb-3 inline-flex min-h-10 items-center text-sm font-bold text-text-muted hover:text-primary"> Back to Arcade</Link>
<p className="crossword-kicker">The evening edition</p>
<h2 className="editorial-title mt-1 text-4xl text-text-heading">Crossword</h2>
<p className="mt-2 max-w-xl text-sm leading-6 text-text-secondary">Choose a terminology bank, set the edition size, and work the clues at your own pace.</p>
</div>
<button onClick={() => setShowImport(true)} className="crossword-primary mt-5 min-h-12 rounded-xl px-5 text-sm font-bold sm:mt-0">Import puzzle bank</button>
</section>
{initialPacks.length === 0 ? (
<section className="crossword-paper rounded-3xl px-6 py-16 text-center">
<div className="mx-auto mb-5 grid w-24 grid-cols-5 gap-1" aria-hidden>{Array.from({ length: 25 }, (_, index) => <i key={index} className={index % 3 === 0 ? "bg-[#2d2117]" : "bg-[#fff8e6]"} />)}</div>
<h3 className="editorial-title text-3xl text-text-heading">Print your first edition</h3>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-text-secondary">Import one JSON object containing exactly 80 clue-and-answer entries.</p>
<button onClick={() => setShowImport(true)} className="crossword-primary mt-6 min-h-11 rounded-xl px-5 text-sm font-bold">Import Crossword JSON</button>
</section>
) : (
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(22rem,.9fr)]">
<section>
<h3 className="crossword-section-label">Puzzle banks</h3>
<div className="space-y-3">
{initialPacks.map((pack) => {
const active = pack.id === effectiveSelectedId;
return <article key={pack.id} className={`crossword-pack rounded-2xl border p-4 ${active ? "is-active" : ""}`}>
<button onClick={() => selectPack(pack)} className="w-full text-left">
<div className="flex items-start justify-between gap-3"><div><h4 className="text-xl font-extrabold text-text-heading">{pack.name}</h4>{pack.description && <p className="mt-1 text-sm leading-5 text-text-secondary">{pack.description}</p>}</div><span className={`mt-1 h-4 w-4 rounded-full border-4 ${active ? "border-primary bg-white" : "border-border bg-bg-surface"}`} aria-hidden /></div>
<div className="mt-3 flex flex-wrap gap-2 text-xs font-bold text-text-muted"><span>80 entries</span><span>Best {pack.bestScore ?? "—"}</span><span>Latest {pack.latestAttempt?.score ?? "—"}</span></div>
</button>
<div className="mt-3 flex justify-end gap-2 border-t border-border-light pt-3"><button onClick={() => renamePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-bg-surface-alt">Rename</button><button onClick={() => deletePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-error-bg hover:text-error">Delete</button></div>
</article>;
})}
</div>
</section>
<aside className="crossword-setup h-fit rounded-3xl p-5 lg:sticky lg:top-6">
<h3 className="editorial-title text-2xl text-text-heading">Choose an edition</h3>
{selected ? <>
<p className="mt-1 text-sm font-bold text-primary">{selected.name}</p>
<fieldset className="mt-5"><legend className="crossword-section-label">Board size</legend><div className="grid grid-cols-2 gap-2">{SIZES.map((option) => <button type="button" key={option.value} aria-pressed={size === option.value} onClick={() => selectSize(option.value)} className={`crossword-size rounded-xl p-3 text-left ${size === option.value ? "is-active" : ""}`}><strong className="block text-sm">{option.label}</strong><span className="text-xs">{option.note}</span></button>)}</div></fieldset>
<CrosswordBoardPreview layout={previewLayout} loading={previewLoading} />
<div className="mt-5 space-y-2">
<label className="crossword-option"><input type="checkbox" checked={instantCheck} onChange={(event) => setInstantCheck(event.target.checked)} /><span><strong>Instant word checks</strong><small>Check only after a word is filled.</small></span></label>
<label className="crossword-option"><input type="checkbox" checked={allowHints} onChange={(event) => setAllowHints(event.target.checked)} /><span><strong>Allow hints</strong><small>Alternate clues and reveals stay available.</small></span></label>
</div>
<div className="mt-5 flex justify-between border-y border-border-light py-3 text-sm"><span className="text-text-secondary">Target words</span><strong>{CROSSWORD_SIZE_TARGETS[size]}</strong></div>
<Link href={playHref} className="crossword-primary mt-5 flex min-h-12 items-center justify-center rounded-xl px-5 text-sm font-extrabold">Open the puzzle</Link>
<div className="mt-7 border-t border-border-light pt-5"><h4 className="crossword-section-label">Recent editions</h4>{attemptsLoading ? <p className="mt-3 text-sm text-text-muted">Loading history</p> : attempts.length === 0 ? <p className="mt-3 text-sm text-text-muted">No attempts yet.</p> : <div className="mt-3 space-y-2">{attempts.slice(0, 5).map((attempt) => <div key={attempt.id} className="flex items-center justify-between rounded-xl bg-bg-surface-alt px-3 py-2 text-sm"><strong>{attempt.score}/{attempt.maxScore}</strong><span className="text-text-muted">{Math.round(attempt.accuracy * 100)}% · {attempt.durationSeconds}s</span></div>)}</div>}</div>
</> : <p className="mt-3 text-sm text-text-muted">Select a pack to configure a puzzle.</p>}
</aside>
</div>
)}
{showImport && <ArcadeImportModal classId={classId} gameType="crossword" onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); router.refresh(); }} />}
</div>
);
}
function CrosswordBoardPreview({ layout, loading }: { layout: CrosswordLayout | null; loading: boolean }) {
const cellSize = layout ? Math.max(3, Math.min(8, Math.floor(190 / Math.max(layout.rows, layout.columns)))) : 6;
return <section className="crossword-board-preview mt-4" aria-label="Selected size board preview" aria-busy={loading}>
<div className="crossword-preview-heading"><span>Layout preview</span>{layout && <small>{layout.entries.length} words · {layout.columns} × {layout.rows}</small>}</div>
<div className="crossword-preview-canvas">
{loading ? <div className="crossword-preview-loading">Composing puzzle</div> : layout ? <div className="crossword-preview-grid" style={{ gridTemplateColumns: `repeat(${layout.columns}, ${cellSize}px)`, gridTemplateRows: `repeat(${layout.rows}, ${cellSize}px)` }}>{layout.cells.map((cell) => <i key={cell.key} style={{ gridColumn: cell.column + 1, gridRow: cell.row + 1 }} />)}</div> : <p>Preview unavailable</p>}
</div>
</section>;
}

View file

@ -0,0 +1,7 @@
import { ConnectionsGame } from "@/components/arcade/ConnectionsGame";
import { CrosswordGame } from "@/components/arcade/CrosswordGame";
export const ARCADE_RENDERERS = {
connections: ConnectionsGame,
crossword: CrosswordGame,
} as const;

View file

@ -79,7 +79,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
</span>
<button
onClick={() => setShowAdd(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl bg-primary px-4 text-sm font-bold text-white transition-colors hover:bg-primary-hover"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
@ -92,7 +92,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
{cards.map((card, index) => (
<div
key={card.id}
className="bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] overflow-hidden"
className="overflow-hidden rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)]"
>
{editingId === card.id ? (
<div className="p-4 space-y-3">
@ -136,7 +136,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
</div>
) : (
<div className="flex items-stretch">
<div className="flex-1 p-4 grid grid-cols-2 gap-4">
<div className="grid flex-1 grid-cols-1 gap-4 p-4 sm:grid-cols-2">
<div>
<span className="block text-xs font-medium text-text-muted uppercase tracking-wider mb-1">
Front

View file

@ -3,6 +3,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { recordStudyActivity } from "@/lib/activityClient";
interface Card {
id: string;
@ -55,6 +56,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
);
const [swipeClass, setSwipeClass] = useState("");
const cardRef = useRef<HTMLDivElement>(null);
const gradingRef = useRef(false);
// Touch/drag state
const dragRef = useRef({ startX: 0, currentX: 0, isDragging: false });
@ -120,7 +122,12 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
// Grade the current card
const gradeCard = useCallback(
(grade: CardResult) => {
if (!currentCard || !hasFlippedOnce) return;
if (!currentCard || !hasFlippedOnce || gradingRef.current) return;
gradingRef.current = true;
if (!isShared) {
recordStudyActivity("FLASHCARD").catch(() => {});
}
setToastMessage(null);
const newResults = { ...results, [currentCard.id]: grade };
@ -130,6 +137,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
setSwipeClass(grade === "correct" ? "animate-swipe-right" : "animate-swipe-left");
setTimeout(() => {
gradingRef.current = false;
setSwipeClass("");
setIsFlipped(false);
setHasFlippedOnce(false);
@ -144,7 +152,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
}
}, 350);
},
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled]
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled, isShared]
);
// Keyboard shortcuts
@ -236,6 +244,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
// Restart handlers
function restartFullSet() {
gradingRef.current = false;
let newOrder: string[];
if (isShuffled) {
newOrder = [...cards.map((c) => c.id)].sort(() => Math.random() - 0.5);
@ -277,13 +286,13 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
if (completed) {
return (
<div className="flex flex-col items-center justify-center py-16">
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-8 max-w-md w-full text-center">
<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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-lg font-semibold text-text-heading mb-2">Set Complete!</h3>
<h3 className="editorial-title mb-2 text-3xl text-text-heading">Set complete</h3>
<div className="flex justify-center gap-6 mb-6">
<div className="text-center">
@ -334,7 +343,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
return (
<div className="flex flex-col items-center">
{/* Running tally */}
<div className="w-full max-w-4xl mb-6">
<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 items-center justify-between text-sm">
<div className="flex items-center gap-3">
<span className="text-success font-medium">{correctCount} </span>
@ -371,16 +380,16 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
>
<div
key={currentCard.id}
className={`relative w-full min-h-[450px] md:min-h-[550px] preserve-3d ${!swipeClass ? "transition-transform duration-300" : ""} ${
className={`relative w-full min-h-[420px] md:min-h-[560px] preserve-3d ${!swipeClass ? "transition-transform duration-300" : ""} ${
isFlipped ? "rotate-x-180" : ""
}`}
>
{/* Front */}
<div className="absolute inset-0 backface-hidden bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-8 md:p-12 flex flex-col items-center justify-center">
<div className="paper-grid absolute inset-0 flex flex-col items-center justify-center rounded-[2rem] border border-border-light bg-bg-surface p-7 shadow-[var(--shadow-card-hover)] backface-hidden md:p-12">
<span className="absolute top-3 right-4 text-xs font-medium text-text-muted uppercase tracking-wider">
Front
</span>
<div className="markdown-content text-center text-xl md:text-3xl text-text-heading leading-relaxed w-full">
<div className="markdown-content w-full text-center font-serif text-2xl font-semibold leading-relaxed text-text-heading md:text-4xl">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{currentCard.front}
</ReactMarkdown>
@ -391,7 +400,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
</div>
{/* Back */}
<div className="absolute inset-0 backface-hidden rotate-x-180 bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-8 md:p-12 flex flex-col items-center justify-center">
<div className="paper-grid absolute inset-0 flex rotate-x-180 flex-col items-center justify-center rounded-[2rem] border border-primary/25 bg-bg-surface p-7 shadow-[var(--shadow-card-hover)] backface-hidden md:p-12">
<span className="absolute top-3 right-4 text-xs font-medium text-text-muted uppercase tracking-wider">
Back
</span>
@ -437,12 +446,12 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
<div className="w-full mt-6">
{/* Primary Action Row (Grading) */}
<div className={`flex items-center justify-center gap-4 min-h-[52px] mb-4 relative z-10 ${!hasFlippedOnce ? 'pointer-events-none' : ''}`}>
<div className={`relative z-10 mb-4 flex min-h-[56px] items-center justify-center gap-3 sm:gap-4 ${!hasFlippedOnce ? 'pointer-events-none' : ''}`}>
{hasFlippedOnce && (
<div className="flex items-center gap-2 md:gap-4 animate-fade-in pointer-events-auto">
<button
onClick={() => gradeCard("missed")}
className="flex items-center gap-2 px-6 py-3 rounded-xl border-2 border-error/30 text-error font-medium hover:bg-error-bg transition-all duration-200 cursor-pointer bg-bg-base/80 backdrop-blur"
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" />
@ -451,7 +460,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
</button>
<button
onClick={() => gradeCard("correct")}
className="flex items-center gap-2 px-6 py-3 rounded-xl border-2 border-success/30 text-success font-medium hover:bg-success-bg transition-all duration-200 cursor-pointer bg-bg-base/80 backdrop-blur"
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" />

View file

@ -74,8 +74,8 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) {
}
onCreated();
} catch (err: any) {
setError(err.message);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to create deck");
} finally {
setSaving(false);
}

View file

@ -2,12 +2,18 @@
import { useState, useEffect } from "react";
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" }) {
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" | "connections" | "crossword" }) {
const [instructions, setInstructions] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveResult, setSaveResult] = useState<"success" | "error" | null>(null);
const [copied, setCopied] = useState(false);
const [packCount, setPackCount] = useState(1);
const batchOverride = importType === "connections" && packCount > 1
? `TEMPORARY BATCH OVERRIDE:\nGenerate exactly ${packCount} distinct Connections packs. Return one raw JSON array containing exactly ${packCount} objects that each follow the schema below. Make the packs meaningfully different from one another. This override takes precedence over any later instruction to return one object.\n\n`
: "";
const displayedInstructions = `${batchOverride}${instructions}`;
useEffect(() => {
fetch(`/api/settings/llm-instructions?type=${importType}`)
@ -52,7 +58,7 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
}
async function handleCopy() {
await navigator.clipboard.writeText(instructions);
await navigator.clipboard.writeText(displayedInstructions);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
@ -67,9 +73,23 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
Copy these instructions and paste them into an LLM chat along with your study material. The LLM will generate JSON you can paste into the Import tab.
</p>
{importType === "connections" && (
<div className="rounded-xl border border-border bg-bg-surface-alt/60 p-4">
<div className="flex items-center justify-between gap-4">
<div>
<label htmlFor="connections-pack-count" className="text-sm font-bold text-text-heading">Number of game packs</label>
<p className="text-xs text-text-muted">This temporarily updates the copied prompt. Your saved instructions stay unchanged.</p>
</div>
<output htmlFor="connections-pack-count" className="grid h-11 min-w-12 place-items-center rounded-lg border border-border bg-bg-surface px-3 text-lg font-extrabold text-text-heading">{packCount}</output>
</div>
<input id="connections-pack-count" type="range" min={1} max={10} step={1} value={packCount} onChange={(event) => setPackCount(Number(event.target.value))} className="mt-4 w-full accent-primary" />
<div className="mt-1 flex justify-between text-[10px] font-bold text-text-muted" aria-hidden><span>1</span><span>5</span><span>10</span></div>
</div>
)}
<textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
value={displayedInstructions}
onChange={(event) => setInstructions(batchOverride && event.target.value.startsWith(batchOverride) ? event.target.value.slice(batchOverride.length) : event.target.value)}
rows={14}
className="w-full px-4 py-3 rounded-lg border border-border bg-bg-surface-alt/50 text-sm text-text-body font-mono leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200 resize-y"
/>

View file

@ -21,7 +21,7 @@ export function ImportModal({
const [activeTab, setActiveTab] = useState<"generate" | "import" | "create">("import");
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm animate-fade-in"
@ -29,14 +29,15 @@ export function ImportModal({
/>
{/* Modal */}
<div className="relative w-full max-w-2xl max-h-[90vh] bg-bg-surface rounded-2xl shadow-[var(--shadow-modal)] animate-slide-up flex flex-col">
<div className="relative flex max-h-[92vh] w-full max-w-2xl animate-slide-up flex-col rounded-t-[2rem] border border-border-light bg-bg-surface shadow-[var(--shadow-modal)] sm:rounded-[2rem]">
{/* Header */}
<div className="flex items-center justify-between px-6 pt-5 pb-0">
<h2 className="text-lg font-semibold text-text-heading">
<h2 className="editorial-title text-2xl text-text-heading">
Import {importType === "flashcards" ? "Flashcard Deck" : "Quiz"}
</h2>
<button
onClick={onClose}
aria-label="Close import dialog"
className="p-1.5 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View file

@ -2,15 +2,16 @@
import { useState } from "react";
import { QuizResults } from "./QuizResults";
import type { QuizAttempt, QuizSummary } from "@/types/study";
interface AttemptHistoryProps {
quiz: any;
attempts: any[];
quiz: QuizSummary;
attempts: QuizAttempt[];
onRetake: (missedIds: string[]) => void;
}
export function AttemptHistory({ quiz, attempts, onRetake }: AttemptHistoryProps) {
const [selectedAttempt, setSelectedAttempt] = useState<any | null>(null);
const [selectedAttempt, setSelectedAttempt] = useState<QuizAttempt | null>(null);
if (selectedAttempt) {
return (

View file

@ -2,20 +2,20 @@
import { Fragment } from "react";
import { scoreQuestion } from "@/lib/scoring";
import type { QuizSummary } from "@/types/study";
interface CategoryBreakdownProps {
quiz: any;
attempt: any;
quiz: QuizSummary;
answeredIds: string[];
answers: Record<string, string[]>;
}
export function CategoryBreakdown({ quiz, attempt, answeredIds, answers }: CategoryBreakdownProps) {
export function CategoryBreakdown({ quiz, answeredIds, answers }: CategoryBreakdownProps) {
// Aggregate stats per category
const stats: Record<string, { earned: number; possible: number }> = {};
answeredIds.forEach((qId) => {
const q = quiz.questions.find((x: any) => x.id === qId);
const q = quiz.questions.find((x) => x.id === qId);
if (!q) return;
const cat = q.category.trim().toLowerCase();
@ -23,7 +23,11 @@ export function CategoryBreakdown({ quiz, attempt, answeredIds, answers }: Categ
stats[cat] = { earned: 0, possible: 0 };
}
const formattedQ = { id: q.id, type: q.type, options: q.options };
const formattedQ = {
id: q.id,
type: q.type as "MULTIPLE_CHOICE" | "SATA",
options: q.options,
};
const points = scoreQuestion(formattedQ, answers[q.id] || []);
stats[cat].earned += points;

View file

@ -4,14 +4,21 @@ 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";
interface QuizResultsProps {
quiz: any; // QuizData
attempt: any; // QuizAttempt
quiz: QuizSummary;
attempt: QuizAttempt;
onRetake: (missedIds: string[]) => void;
onClose?: () => void;
}
interface ReviewItem {
question: QuizQuestion;
score: number;
selections: string[];
}
export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsProps) {
let answers: Record<string, string[]> = {};
try {
@ -19,16 +26,16 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
} catch {}
// Determine non-full-credit questions for review list
const reviewItems: any[] = [];
const reviewItems: ReviewItem[] = [];
const missedIds: string[] = [];
const answeredIds = Object.keys(answers);
const questionsToReview = quiz.questions.filter((q: any) => answeredIds.includes(q.id));
const questionsToReview = quiz.questions.filter((q) => answeredIds.includes(q.id));
questionsToReview.forEach((q: any) => {
questionsToReview.forEach((q) => {
const formattedQ = {
id: q.id,
type: q.type,
type: q.type as "MULTIPLE_CHOICE" | "SATA",
options: q.options,
};
const score = scoreQuestion(formattedQ, answers[q.id] || []);
@ -50,7 +57,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
<div className="max-w-3xl mx-auto space-y-8 animate-fade-in pb-12">
{/* Top Banner */}
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-8 text-center relative overflow-hidden">
<div className="relative overflow-hidden rounded-[2rem] border border-border-light bg-bg-surface p-6 text-center shadow-[var(--shadow-card-hover)] sm:p-8">
{percentage >= 80 && (
<div className="absolute top-0 left-0 w-full h-2 bg-success"></div>
)}
@ -61,7 +68,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
<div className="absolute top-0 left-0 w-full h-2 bg-error"></div>
)}
<h2 className="text-2xl font-bold text-text-heading mb-2">Quiz Complete</h2>
<h2 className="editorial-title mb-2 text-4xl text-text-heading">Quiz complete</h2>
{attempt.isPartialRetake && (
<p className="text-sm text-text-secondary mb-4">(Partial Retake)</p>
)}
@ -71,7 +78,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
<span className="text-lg text-text-muted mb-1 font-medium">({parseFloat(attempt.score.toFixed(2))} / {attempt.maxScore})</span>
</div>
<div className="flex justify-center gap-4">
<div className="flex flex-col justify-center gap-3 sm:flex-row">
{onClose && (
<button
onClick={onClose}
@ -92,7 +99,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
<button
onClick={() => {
const allIds = quiz.questions.map((q: any) => q.id).sort(() => Math.random() - 0.5);
const allIds = quiz.questions.map((q) => 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"
@ -105,7 +112,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
{/* Category Breakdown */}
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-6 md:p-8">
<h3 className="text-lg font-bold text-text-heading mb-6">Category Breakdown</h3>
<CategoryBreakdown quiz={quiz} attempt={attempt} answeredIds={answeredIds} answers={answers} />
<CategoryBreakdown quiz={quiz} answeredIds={answeredIds} answers={answers} />
</div>
{/* Review List */}
@ -113,7 +120,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, idx) => {
{reviewItems.map((item) => {
const q = item.question;
const sels = item.selections;
return (
@ -128,7 +135,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
</div>
<div className="space-y-2 mb-6">
{q.options.map((opt: any) => {
{q.options.map((opt) => {
const isSelected = sels.includes(opt.id);
let stateClass = "cursor-default opacity-80";
let icon = null;

View file

@ -1,10 +1,12 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } 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";
interface Option {
id: string;
@ -39,6 +41,7 @@ interface QuizViewerProps {
}
export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: QuizViewerProps) {
const submittingQuestionRef = useRef<string | null>(null);
const [order, setOrder] = useState<string[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [answers, setAnswers] = useState<Record<string, string[]>>({});
@ -46,7 +49,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
const [shuffledOptions, setShuffledOptions] = useState<Record<string, Option[]>>({});
const [loading, setLoading] = useState(true);
const [resultsData, setResultsData] = useState<any | null>(null);
const [resultsData, setResultsData] = useState<QuizAttempt | null>(null);
const [toastMessage, setToastMessage] = useState<string | null>(null);
// Auto-hide toast
@ -194,7 +197,11 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
}
function handleSubmit() {
if (isSubmitted || !currentQId) return;
if (isSubmitted || !currentQId || submittingQuestionRef.current === currentQId) return;
submittingQuestionRef.current = currentQId;
if (!isShared) {
recordStudyActivity("QUIZ_QUESTION").catch(() => {});
}
const newSubmitted = [...submittedAnswers, currentQId];
setSubmittedAnswers(newSubmitted);
saveProgress(currentIndex, answers);
@ -202,6 +209,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
function handleNext() {
if (currentIndex + 1 < order.length) {
submittingQuestionRef.current = null;
const nextIdx = currentIndex + 1;
setCurrentIndex(nextIdx);
saveProgress(nextIdx, answers);
@ -290,6 +298,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
setCurrentIndex(0);
setAnswers({});
setSubmittedAnswers([]);
submittingQuestionRef.current = null;
setResultsData(null);
// Re-shuffle options for the new attempt
@ -320,10 +329,10 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
)}
{/* Main Unified Card */}
<div className="w-full max-w-3xl bg-bg-surface-alt rounded-2xl border border-border-light shadow-[var(--shadow-card)] overflow-hidden mb-8 mt-12">
<div className="mb-8 mt-4 w-full max-w-4xl overflow-hidden rounded-[2rem] border border-border-light bg-bg-surface shadow-[var(--shadow-card-hover)] md:mt-8">
{/* Progress Header */}
<div className="px-6 md:px-8 py-5 flex items-center gap-4">
<div className="flex items-center gap-4 border-b border-border-light bg-bg-surface-alt/55 px-5 py-4 md:px-8">
<div className="text-sm font-mono text-text-secondary whitespace-nowrap tracking-wider">
Q {currentIndex + 1} / {order.length}
</div>
@ -339,20 +348,20 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
</div>
{/* Question Area */}
<div className="px-6 md:px-8 pb-8 pt-2">
<div className="px-5 pb-6 pt-6 md:px-10 md:pb-10 md:pt-8">
{/* Tags */}
<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">
<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">
{currentQ.category}
</span>
<span className="inline-flex px-3 py-1 rounded-md bg-amber-500/10 text-amber-600 text-xs font-bold uppercase tracking-wider">
<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">
{currentQ.type === "MULTIPLE_CHOICE" ? "Multiple Choice" : "Select All That Apply"}
</span>
</div>
{/* Prompt */}
<div className="markdown-content text-xl text-text-heading mb-8 font-serif leading-relaxed font-semibold">
<div className="markdown-content editorial-title mb-8 text-2xl leading-relaxed text-text-heading md:text-3xl">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{currentQ.prompt}
</ReactMarkdown>
@ -374,7 +383,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
} else if (!isSelected && opt.isCorrect) {
stateClass = "border-success/50 bg-success/5 text-text-heading";
rightIcon = (
<div className="text-success flex items-center gap-1.5 text-sm font-bold">
<div className="flex items-center gap-1.5 text-sm font-bold text-success">
<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>
@ -392,7 +401,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
<div
key={opt.id}
onClick={() => toggleOption(opt.id)}
className={`flex items-center gap-4 px-5 py-4 rounded-xl border transition-all duration-200 ${stateClass}`}
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}`}
>
<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 ${
@ -416,7 +425,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
{opt.text}
</div>
{rightIcon && (
<div className="flex-shrink-0 pl-2">
<div className="col-start-2 min-w-0 md:flex-shrink-0 md:pl-2">
{rightIcon}
</div>
)}
@ -478,7 +487,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
})()}
{/* Action Button */}
<div className="mt-10 flex">
<div className="sticky bottom-3 z-10 mt-10 flex rounded-2xl bg-bg-surface/90 p-1 backdrop-blur md:static md:bg-transparent md:p-0">
{!isSubmitted ? (
<button
onClick={handleSubmit}

View file

@ -0,0 +1,365 @@
"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);
return () => window.clearTimeout(timer);
}, [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>
);
}

View file

@ -0,0 +1,220 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { SpacedRepetitionRating } from "@/lib/spacedRepetition";
interface StudyState {
set: {
id: string;
name: string;
description: string | null;
class: { slug: string; name: string };
stats: {
totalCards: number;
reviewedCards: number;
unseenCards: number;
dueCards: number;
learningAheadCards: number;
newCardsAvailable: number;
availableCards: number;
nextDue: string | null;
};
};
currentCard: {
id: string;
front: string;
back: string;
sourceDeckName: string;
stateVersion: string | null;
previews: Array<{
rating: SpacedRepetitionRating;
due: string;
scheduledDays: number;
}>;
} | null;
}
const ratingStyles: Record<SpacedRepetitionRating, string> = {
AGAIN: "border-error/35 text-error hover:bg-error-bg",
HARD: "border-accent/40 text-accent hover:bg-bg-surface-alt",
GOOD: "border-success/35 text-success hover:bg-success-bg",
EASY: "border-primary/35 text-primary hover:bg-bg-callout",
};
function intervalLabel(due: string, scheduledDays: number) {
if (scheduledDays >= 1) {
if (scheduledDays < 30) return `${scheduledDays}d`;
const months = Math.round(scheduledDays / 30);
if (months < 12) return `${months}mo`;
return `${Math.round(months / 12)}y`;
}
const minutes = Math.max(1, Math.round((new Date(due).getTime() - Date.now()) / 60000));
if (minutes < 60) return `${minutes}m`;
const hours = Math.round(minutes / 60);
return `${hours}h`;
}
function dueLabel(due: string | null) {
if (!due) return "No reviews scheduled";
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(due));
}
export function SpacedRepetitionViewer() {
const { classSlug, setId } = useParams<{ classSlug: string; setId: string }>();
const router = useRouter();
const [data, setData] = useState<StudyState | null>(null);
const [loading, setLoading] = useState(true);
const [flipped, setFlipped] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const response = await fetch(`/api/spaced-repetition-sets/${setId}/study`, {
cache: "no-store",
});
if (!response.ok) throw new Error("Repetition set not found");
const nextData = (await response.json()) as StudyState;
if (nextData.set.class.slug !== classSlug) throw new Error("Repetition set not found");
setData(nextData);
setFlipped(false);
setError(null);
} catch {
router.push(`/${classSlug}/spaced-repetition`);
} finally {
setLoading(false);
}
}, [classSlug, router, setId]);
useEffect(() => {
const timer = window.setTimeout(() => void load(), 0);
return () => window.clearTimeout(timer);
}, [load]);
const submitRating = useCallback(async (rating: SpacedRepetitionRating) => {
if (!data?.currentCard || saving) return;
setSaving(true);
setError(null);
try {
const response = await fetch(`/api/spaced-repetition-sets/${setId}/reviews`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
flashcardId: data.currentCard.id,
rating,
expectedStateVersion: data.currentCard.stateVersion,
}),
});
if (response.status === 409) {
await load();
setError("That review was already saved. The queue has been refreshed.");
return;
}
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(body?.error ?? "Unable to save review");
}
setData(await response.json());
window.dispatchEvent(new Event("spaced-repetition-updated"));
setFlipped(false);
} catch (reviewError) {
setError(reviewError instanceof Error ? reviewError.message : "Unable to save review");
} finally {
setSaving(false);
}
}, [data, load, saving, setId]);
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (!data?.currentCard || saving) return;
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
setFlipped((current) => !current);
return;
}
if (!flipped) return;
const keyboardRatings: Record<string, SpacedRepetitionRating> = {
"1": "AGAIN", "2": "HARD", "3": "GOOD", "4": "EASY",
};
const rating = keyboardRatings[event.key];
if (rating) submitRating(rating);
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [data, flipped, saving, submitRating]);
if (loading || !data) {
return <div className="mx-auto max-w-5xl animate-subtle-pulse py-16 text-center text-text-muted">Loading review queue</div>;
}
const card = data.currentCard;
return (
<div className="mx-auto w-full max-w-5xl py-1 pb-16 md:py-3">
<header className="mb-6 rounded-2xl border border-border-light bg-bg-surface/75 p-4 shadow-[var(--shadow-card)] sm:p-5">
<button onClick={() => router.push(`/${classSlug}/spaced-repetition`)} className="mb-2 inline-flex min-h-10 items-center gap-2 text-sm text-text-muted hover:text-text-heading"><span aria-hidden></span> Back to repetition sets</button>
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Spaced repetition</p><h1 className="editorial-title mt-1 text-3xl text-text-heading">{data.set.name}</h1>{data.set.description && <p className="mt-1 text-sm text-text-secondary">{data.set.description}</p>}</div>
<div className="flex flex-wrap gap-2 text-xs">
<span className="rounded-full bg-error-bg px-3 py-1.5 text-error">{data.set.stats.dueCards} due</span>
{data.set.stats.learningAheadCards > 0 && <span className="rounded-full bg-bg-callout px-3 py-1.5 text-primary">{data.set.stats.learningAheadCards} learning</span>}
<span className="rounded-full bg-success-bg px-3 py-1.5 text-success">{data.set.stats.newCardsAvailable} new</span>
<span className="rounded-full bg-bg-surface-alt px-3 py-1.5 text-text-muted">{data.set.stats.reviewedCards}/{data.set.stats.totalCards} introduced</span>
</div>
</div>
</header>
{error && <div role="alert" className="mb-5 flex items-center justify-between rounded-xl border border-error/30 bg-error-bg px-4 py-3 text-sm text-error"><span>{error}</span><button onClick={() => setError(null)} aria-label="Dismiss error">×</button></div>}
{!card ? (
<div className="rounded-3xl border border-success/25 bg-bg-surface p-8 text-center shadow-[var(--shadow-card)] sm:p-12">
<div className="mx-auto grid h-16 w-16 place-items-center rounded-full bg-success-bg text-3xl text-success"></div>
<h2 className="editorial-title mt-5 text-3xl text-text-heading">Youre caught up</h2>
<p className="mt-2 text-text-secondary">There are no due cards and todays new-card allowance is complete.</p>
<p className="mt-5 text-sm font-semibold text-text-muted">Next review: {dueLabel(data.set.stats.nextDue)}</p>
<button onClick={() => router.push(`/${classSlug}/spaced-repetition`)} className="mt-7 min-h-11 rounded-xl bg-primary px-6 font-bold text-white hover:bg-primary-hover">Back to sets</button>
</div>
) : (
<div className="flex flex-col items-center">
<div className="mb-4 flex w-full items-center justify-between text-sm text-text-muted"><span>From {card.sourceDeckName}</span><span>{data.set.stats.availableCards} available</span></div>
<div
role="button"
tabIndex={0}
aria-label={flipped ? "Showing card answer. Press Space to show front." : "Showing card front. Press Space to reveal answer."}
onClick={() => !saving && setFlipped((current) => !current)}
className="perspective-1000 relative w-full cursor-pointer select-none"
>
<div className={`preserve-3d relative min-h-[420px] w-full transition-transform duration-300 md:min-h-[560px] ${flipped ? "rotate-x-180" : ""}`}>
<div className="paper-grid backface-hidden absolute inset-0 flex flex-col items-center justify-center rounded-[2rem] border border-border-light bg-bg-surface p-7 shadow-[var(--shadow-card-hover)] md:p-12">
<span className="absolute right-5 top-4 text-xs font-bold uppercase tracking-wider text-text-muted">Front</span>
<div className="markdown-content w-full text-center font-serif text-2xl font-semibold leading-relaxed text-text-heading md:text-4xl"><ReactMarkdown remarkPlugins={[remarkGfm]}>{card.front}</ReactMarkdown></div>
<p className="mt-7 text-xs text-text-muted">Tap or press Space to reveal the answer</p>
</div>
<div className="paper-grid backface-hidden absolute inset-0 flex rotate-x-180 flex-col items-center justify-center rounded-[2rem] border border-primary/25 bg-bg-surface p-7 shadow-[var(--shadow-card-hover)] md:p-12">
<span className="absolute right-5 top-4 text-xs font-bold uppercase tracking-wider text-text-muted">Back</span>
<div className="markdown-content max-h-[390px] w-full overflow-y-auto text-center text-lg leading-relaxed text-text-body md:max-h-[450px] md:text-xl"><ReactMarkdown remarkPlugins={[remarkGfm]}>{card.back}</ReactMarkdown></div>
</div>
</div>
</div>
<div className={`mt-6 grid w-full grid-cols-2 gap-2 transition-opacity sm:grid-cols-4 ${flipped ? "opacity-100" : "pointer-events-none opacity-0"}`} aria-hidden={!flipped}>
{card.previews.map((preview, index) => (
<button key={preview.rating} disabled={saving || !flipped} onClick={() => submitRating(preview.rating)} className={`min-h-16 rounded-xl border-2 bg-bg-surface px-3 py-2 text-sm font-extrabold transition-colors disabled:opacity-50 ${ratingStyles[preview.rating]}`}>
<span className="block">{preview.rating[0]}{preview.rating.slice(1).toLowerCase()}</span>
<span className="mt-0.5 block text-xs font-semibold opacity-75">{index + 1} · {intervalLabel(preview.due, preview.scheduledDays)}</span>
</button>
))}
</div>
<p className="mt-4 text-center text-xs text-text-muted">{flipped ? "Choose 14 based on how well you remembered" : "Space to flip"}</p>
</div>
)}
</div>
);
}

View file

@ -2,14 +2,12 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ClassTabs } from "./ClassTabs";
interface ClassHeaderProps {
classSlug: string;
className: string;
}
export function ClassHeader({ classSlug, className }: ClassHeaderProps) {
export function ClassHeader({ className }: ClassHeaderProps) {
const pathname = usePathname();
// If we are deeper than /[classSlug]/[tab], hide this header to save space
@ -23,21 +21,21 @@ export function ClassHeader({ classSlug, className }: ClassHeaderProps) {
}
return (
<>
<div className="mb-6">
<div className="mb-8 flex flex-col gap-4 border-b border-border-light pb-7 sm:flex-row sm:items-end sm:justify-between">
<div>
<Link
href="/"
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-text-heading mb-2 transition-colors"
className="mb-3 inline-flex min-h-10 items-center gap-2 rounded-lg pr-3 text-xs font-bold uppercase tracking-[0.15em] text-text-muted transition-colors hover:text-primary"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
All Classes
Library
</Link>
<h1 className="text-2xl font-bold text-text-heading">{className}</h1>
<p className="text-xs font-bold uppercase tracking-[0.18em] text-primary">Active class</p>
<h1 className="editorial-title mt-1 text-4xl text-text-heading sm:text-5xl">{className}</h1>
</div>
<p className="max-w-sm text-sm leading-6 text-text-secondary">Choose a mode, then settle in for a focused study session.</p>
</div>
<ClassTabs classSlug={classSlug} />
</>
);
}

View file

@ -12,8 +12,8 @@ export function ClassTabs({ classSlug }: ClassTabsProps) {
const pathname = usePathname();
return (
<div className="border-b border-border-light mb-6">
<nav className="flex gap-0" aria-label="Study modes">
<div className="mb-8 border-b border-border-light">
<nav className="flex gap-2" aria-label="Study modes">
{STUDY_MODES.map((mode) => {
const href = `/${classSlug}/${mode.path}`;
const isActive = pathname === href || pathname.startsWith(href + "/");
@ -22,7 +22,7 @@ export function ClassTabs({ classSlug }: ClassTabsProps) {
<Link
key={mode.key}
href={href}
className={`relative px-5 py-3 text-sm font-medium transition-colors duration-200 ${
className={`relative min-h-12 px-4 py-3 text-sm font-bold transition-colors duration-200 ${
isActive
? "text-primary"
: "text-text-muted hover:text-text-heading"
@ -30,7 +30,7 @@ export function ClassTabs({ classSlug }: ClassTabsProps) {
>
{mode.label}
{isActive && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full" />
<span className="absolute bottom-0 left-2 right-2 h-[3px] rounded-t-full bg-primary" />
)}
</Link>
);

View file

@ -1,11 +1,56 @@
"use client";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import Link from "next/link";
import { ThemeToggle } from "./ThemeToggle";
import { STUDY_MODES } from "@/config/studyModes";
function Mark() {
return (
<span className="grid h-9 w-9 place-items-center rounded-xl bg-primary text-sm font-extrabold text-white shadow-[0_8px_22px_color-mix(in_srgb,var(--theme-primary)_28%,transparent)]">
S
</span>
);
}
export function Navbar() {
const router = useRouter();
const pathname = usePathname();
const [open, setOpen] = useState(false);
const parts = pathname.split("/").filter(Boolean);
const classSlug = parts[0] && parts[0] !== "shared" ? parts[0] : null;
const [hasReadyCards, setHasReadyCards] = useState(false);
const refreshDueCards = useCallback(async () => {
if (!classSlug) return;
try {
const response = await fetch("/api/classes");
if (!response.ok) return;
const classes = await response.json() as Array<{
slug: string;
spacedRepetitionReadyCards: number;
}>;
const currentClass = classes.find((classItem) => classItem.slug === classSlug);
setHasReadyCards((currentClass?.spacedRepetitionReadyCards ?? 0) > 0);
} catch {
// Keep the last known state when a background refresh fails.
}
}, [classSlug]);
useEffect(() => {
const initialRefresh = window.setTimeout(() => void refreshDueCards(), 0);
const interval = window.setInterval(refreshDueCards, 60_000);
window.addEventListener("focus", refreshDueCards);
window.addEventListener("spaced-repetition-updated", refreshDueCards);
return () => {
window.clearTimeout(initialRefresh);
window.clearInterval(interval);
window.removeEventListener("focus", refreshDueCards);
window.removeEventListener("spaced-repetition-updated", refreshDueCards);
};
}, [refreshDueCards]);
async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" });
@ -13,40 +58,90 @@ export function Navbar() {
router.refresh();
}
return (
<nav className="bg-bg-surface border-b border-border-light">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-14">
const nav = (
<>
<Link href="/" onClick={() => setOpen(false)} className="mb-9 flex items-center gap-3">
<Mark />
<span>
<span className="block text-[11px] font-bold uppercase tracking-[0.2em] text-text-muted">Your</span>
<span className="editorial-title block text-xl leading-none text-text-heading">Study Desk</span>
</span>
</Link>
<nav className="space-y-1" aria-label="Primary navigation">
<Link
href="/"
className="flex items-center gap-2.5 text-text-heading font-semibold hover:text-primary transition-colors"
onClick={() => setOpen(false)}
className={`flex min-h-11 items-center gap-3 rounded-xl px-3 text-sm font-semibold transition-colors ${pathname === "/" ? "bg-primary text-white shadow-sm" : "text-text-secondary hover:bg-bg-surface-alt hover:text-text-heading"}`}
>
<svg
className="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
Study
<span aria-hidden className="text-lg"></span> Library
</Link>
<div className="flex items-center gap-4">
<ThemeToggle />
<button
onClick={handleLogout}
className="text-sm text-text-muted hover:text-text-heading transition-colors cursor-pointer"
{classSlug && (
<div className="pt-6">
<p className="mb-2 px-3 text-[10px] font-bold uppercase tracking-[0.18em] text-text-muted">Current class</p>
{STUDY_MODES.map((mode, index) => {
const isActive = pathname.includes(`/${mode.path}`);
return (
<Link
key={mode.key}
href={`/${classSlug}/${mode.path}`}
onClick={() => setOpen(false)}
className={`${index > 0 ? "mt-1 " : ""}flex min-h-11 items-center gap-3 rounded-xl px-3 text-sm font-semibold transition-colors ${isActive ? "bg-bg-callout text-primary" : "text-text-secondary hover:bg-bg-surface-alt hover:text-text-heading"}`}
>
Sign out
<span aria-hidden>{mode.icon}</span> {mode.label}
{mode.key === "spaced-repetition" && hasReadyCards && (
<span
className="ml-auto grid h-5 w-5 place-items-center rounded-full bg-error-bg text-xs font-extrabold text-error"
title="Spaced repetition cards are ready to study"
>
<span aria-hidden>!</span>
<span className="sr-only">Cards ready to study</span>
</span>
)}
</Link>
);
})}
</div>
)}
</nav>
<div className="mt-auto border-t border-border-light pt-4">
<div className="flex items-center justify-between rounded-xl px-2 py-1.5">
<span className="text-xs font-semibold text-text-muted">Appearance</span>
<ThemeToggle />
</div>
<button onClick={handleLogout} className="mt-1 flex min-h-11 w-full items-center gap-3 rounded-xl px-3 text-left text-sm font-semibold text-text-secondary transition-colors hover:bg-error-bg hover:text-error">
<span aria-hidden></span> Sign out
</button>
</div>
</>
);
return (
<>
<aside className="app-sidebar fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-border-light bg-bg-surface/88 px-4 py-5 backdrop-blur-xl md:flex">
{nav}
</aside>
<header className="app-mobile-header sticky top-0 z-40 flex h-16 items-center justify-between border-b border-border-light bg-bg-surface/88 px-4 backdrop-blur-xl md:hidden">
<Link href="/" className="flex items-center gap-2.5">
<Mark />
<span className="editorial-title text-lg text-text-heading">Study Desk</span>
</Link>
<button onClick={() => setOpen(true)} className="grid h-11 w-11 place-items-center rounded-xl border border-border-light bg-bg-surface-alt text-xl text-text-heading" aria-label="Open navigation" aria-expanded={open}>
</button>
</header>
{open && (
<div className="fixed inset-0 z-50 md:hidden">
<button className="absolute inset-0 bg-black/45 backdrop-blur-sm" onClick={() => setOpen(false)} aria-label="Close navigation" />
<aside className="animate-slide-up absolute inset-y-0 right-0 flex w-[min(86vw,21rem)] flex-col border-l border-border-light bg-bg-surface p-5 shadow-[var(--shadow-modal)]">
<button onClick={() => setOpen(false)} className="absolute right-4 top-4 grid h-10 w-10 place-items-center rounded-xl text-xl text-text-muted hover:bg-bg-surface-alt" aria-label="Close navigation">×</button>
{nav}
</aside>
</div>
</div>
</nav>
)}
</>
);
}

View file

@ -1,20 +1,39 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
interface ShareMenuProps {
targetType: "DECK" | "QUIZ" | "GROUP";
contentId: string;
classSlug: string;
compact?: boolean;
}
export function ShareMenu({ targetType, contentId, classSlug }: ShareMenuProps) {
export function ShareMenu({ targetType, contentId, classSlug, compact = false }: ShareMenuProps) {
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
const [isGroupShared, setIsGroupShared] = useState(false);
const [groupName, setGroupName] = useState<string | null>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handlePointerDown(event: PointerEvent) {
const target = event.target as Node;
if (buttonRef.current?.contains(target) || menuRef.current?.contains(target)) {
return;
}
setOpen(false);
}
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [open]);
useEffect(() => {
if (!open) return;
@ -45,8 +64,15 @@ export function ShareMenu({ targetType, contentId, classSlug }: ShareMenuProps)
async function handleCopy() {
if (!token) return;
const typeStr = targetType === "GROUP" ? "groups" : (targetType === "DECK" ? "flashcards" : "quizzes");
const url = `${window.location.origin}/shared/${classSlug}/${typeStr}/${token}`;
const typeStr = isGroupShared
? "groups"
: targetType === "GROUP"
? "groups"
: targetType === "DECK"
? "flashcards"
: "quizzes";
const itemQuery = isGroupShared ? `?itemId=${encodeURIComponent(contentId)}` : "";
const url = `${window.location.origin}/shared/${classSlug}/${typeStr}/${token}${itemQuery}`;
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
@ -55,8 +81,9 @@ export function ShareMenu({ targetType, contentId, classSlug }: ShareMenuProps)
return (
<div className="relative">
<button
ref={buttonRef}
onClick={() => setOpen(!open)}
className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
className={`${compact ? "h-8 w-8 rounded-lg" : "h-10 w-10 rounded-xl"} grid place-items-center text-text-muted transition-colors hover:bg-bg-surface-alt hover:text-primary`}
title="Share"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -67,8 +94,8 @@ export function ShareMenu({ targetType, contentId, classSlug }: ShareMenuProps)
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute right-0 mt-2 w-72 bg-bg-surface rounded-xl shadow-[var(--shadow-modal)] border border-border-light z-50 p-4 animate-slide-up origin-top-right">
<h4 className="text-sm font-semibold text-text-heading mb-3">Public Sharing</h4>
<div ref={menuRef} className="absolute right-0 z-50 mt-2 w-72 origin-top-right animate-slide-up rounded-2xl border border-border-light bg-bg-surface p-5 shadow-[var(--shadow-modal)]">
<h4 className="editorial-title mb-3 text-xl text-text-heading">Public sharing</h4>
{loading ? (
<div className="h-8 bg-bg-surface-alt rounded w-full animate-subtle-pulse" />

View file

@ -12,7 +12,7 @@ export function ThemeToggle() {
}, []);
if (!mounted) {
return <div className="w-8 h-8" />; // placeholder to prevent layout shift
return <div className="h-10 w-10" />; // placeholder to prevent layout shift
}
function toggleTheme() {
@ -30,9 +30,9 @@ export function ThemeToggle() {
return (
<button
onClick={toggleTheme}
className="p-1.5 text-text-muted hover:text-text-heading transition-colors cursor-pointer rounded-lg hover:bg-bg-surface-alt flex items-center justify-center"
className="flex h-10 w-10 items-center justify-center rounded-xl border border-border-light bg-bg-surface-alt text-text-muted transition-colors hover:border-primary/30 hover:text-primary"
title={isDark ? "Switch to Light Mode" : "Switch to Dark Mode"}
aria-label="Toggle Dark Mode"
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
>
{isDark ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">

34
src/config/arcadeGames.ts Normal file
View file

@ -0,0 +1,34 @@
export const ARCADE_GAMES = [
{
key: "connections",
name: "Connections",
path: "connections",
available: true,
estimatedMinutes: "510 min",
description: "Find four hidden relationships among sixteen terms.",
},
{
key: "crossword",
name: "Crossword",
path: "crossword",
available: true,
estimatedMinutes: "1045 min",
description: "Turn course terminology into an interlocking word puzzle.",
},
{
key: "falling-blocks",
name: "Falling Blocks",
path: "falling-blocks",
available: false,
estimatedMinutes: "Coming soon",
description: "Match prompts to answers before the board fills up.",
},
{
key: "asteroid-defense",
name: "Asteroid Defense",
path: "asteroid-defense",
available: false,
estimatedMinutes: "Coming soon",
description: "Defend the station by targeting the correct answers.",
},
] as const;

View file

@ -1,6 +1,8 @@
export const STUDY_MODES = [
{ key: "flashcards", label: "Flashcards", path: "flashcards" },
{ key: "quizzes", label: "Quizzes", path: "quizzes" },
{ key: "flashcards", label: "Flashcards", path: "flashcards", icon: "▤" },
{ key: "quizzes", label: "Quizzes", path: "quizzes", icon: "✓" },
{ key: "spaced-repetition", label: "Spaced Repetition", path: "spaced-repetition", icon: "↻" },
{ key: "arcade", label: "Arcade", path: "arcade", icon: "◆" },
// Future modes get added here — the layout, tab bar, and auth middleware need no changes.
] as const;

View file

@ -72,8 +72,38 @@ export type AuthSecurity = Prisma.AuthSecurityModel
*
*/
export type Setting = Prisma.SettingModel
/**
* Model StudyActivity
*
*/
export type StudyActivity = Prisma.StudyActivityModel
/**
* Model ArcadePack
*
*/
export type ArcadePack = Prisma.ArcadePackModel
/**
* Model ArcadeAttempt
*
*/
export type ArcadeAttempt = Prisma.ArcadeAttemptModel
/**
* Model MaterialGroup
*
*/
export type MaterialGroup = Prisma.MaterialGroupModel
/**
* Model SpacedRepetitionSet
*
*/
export type SpacedRepetitionSet = Prisma.SpacedRepetitionSetModel
/**
* Model SpacedRepetitionSetDeck
*
*/
export type SpacedRepetitionSetDeck = Prisma.SpacedRepetitionSetDeckModel
/**
* Model SpacedRepetitionCardState
*
*/
export type SpacedRepetitionCardState = Prisma.SpacedRepetitionCardStateModel

View file

@ -96,8 +96,38 @@ export type AuthSecurity = Prisma.AuthSecurityModel
*
*/
export type Setting = Prisma.SettingModel
/**
* Model StudyActivity
*
*/
export type StudyActivity = Prisma.StudyActivityModel
/**
* Model ArcadePack
*
*/
export type ArcadePack = Prisma.ArcadePackModel
/**
* Model ArcadeAttempt
*
*/
export type ArcadeAttempt = Prisma.ArcadeAttemptModel
/**
* Model MaterialGroup
*
*/
export type MaterialGroup = Prisma.MaterialGroupModel
/**
* Model SpacedRepetitionSet
*
*/
export type SpacedRepetitionSet = Prisma.SpacedRepetitionSetModel
/**
* Model SpacedRepetitionSetDeck
*
*/
export type SpacedRepetitionSetDeck = Prisma.SpacedRepetitionSetDeckModel
/**
* Model SpacedRepetitionCardState
*
*/
export type SpacedRepetitionCardState = Prisma.SpacedRepetitionCardStateModel

File diff suppressed because one or more lines are too long

View file

@ -395,7 +395,13 @@ export const ModelName = {
ShareLink: 'ShareLink',
AuthSecurity: 'AuthSecurity',
Setting: 'Setting',
MaterialGroup: 'MaterialGroup'
StudyActivity: 'StudyActivity',
ArcadePack: 'ArcadePack',
ArcadeAttempt: 'ArcadeAttempt',
MaterialGroup: 'MaterialGroup',
SpacedRepetitionSet: 'SpacedRepetitionSet',
SpacedRepetitionSetDeck: 'SpacedRepetitionSetDeck',
SpacedRepetitionCardState: 'SpacedRepetitionCardState'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@ -411,7 +417,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "class" | "deck" | "flashcard" | "quizSet" | "question" | "answerOption" | "studyProgress" | "quizAttempt" | "shareLink" | "authSecurity" | "setting" | "materialGroup"
modelProps: "class" | "deck" | "flashcard" | "quizSet" | "question" | "answerOption" | "studyProgress" | "quizAttempt" | "shareLink" | "authSecurity" | "setting" | "studyActivity" | "arcadePack" | "arcadeAttempt" | "materialGroup" | "spacedRepetitionSet" | "spacedRepetitionSetDeck" | "spacedRepetitionCardState"
txIsolationLevel: TransactionIsolationLevel
}
model: {
@ -1229,6 +1235,228 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
StudyActivity: {
payload: Prisma.$StudyActivityPayload<ExtArgs>
fields: Prisma.StudyActivityFieldRefs
operations: {
findUnique: {
args: Prisma.StudyActivityFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload> | null
}
findUniqueOrThrow: {
args: Prisma.StudyActivityFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>
}
findFirst: {
args: Prisma.StudyActivityFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload> | null
}
findFirstOrThrow: {
args: Prisma.StudyActivityFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>
}
findMany: {
args: Prisma.StudyActivityFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>[]
}
create: {
args: Prisma.StudyActivityCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>
}
createMany: {
args: Prisma.StudyActivityCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.StudyActivityCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>[]
}
delete: {
args: Prisma.StudyActivityDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>
}
update: {
args: Prisma.StudyActivityUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>
}
deleteMany: {
args: Prisma.StudyActivityDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.StudyActivityUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.StudyActivityUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>[]
}
upsert: {
args: Prisma.StudyActivityUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>
}
aggregate: {
args: Prisma.StudyActivityAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateStudyActivity>
}
groupBy: {
args: Prisma.StudyActivityGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.StudyActivityGroupByOutputType>[]
}
count: {
args: Prisma.StudyActivityCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.StudyActivityCountAggregateOutputType> | number
}
}
}
ArcadePack: {
payload: Prisma.$ArcadePackPayload<ExtArgs>
fields: Prisma.ArcadePackFieldRefs
operations: {
findUnique: {
args: Prisma.ArcadePackFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload> | null
}
findUniqueOrThrow: {
args: Prisma.ArcadePackFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
}
findFirst: {
args: Prisma.ArcadePackFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload> | null
}
findFirstOrThrow: {
args: Prisma.ArcadePackFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
}
findMany: {
args: Prisma.ArcadePackFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>[]
}
create: {
args: Prisma.ArcadePackCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
}
createMany: {
args: Prisma.ArcadePackCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.ArcadePackCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>[]
}
delete: {
args: Prisma.ArcadePackDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
}
update: {
args: Prisma.ArcadePackUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
}
deleteMany: {
args: Prisma.ArcadePackDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.ArcadePackUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.ArcadePackUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>[]
}
upsert: {
args: Prisma.ArcadePackUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
}
aggregate: {
args: Prisma.ArcadePackAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateArcadePack>
}
groupBy: {
args: Prisma.ArcadePackGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ArcadePackGroupByOutputType>[]
}
count: {
args: Prisma.ArcadePackCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ArcadePackCountAggregateOutputType> | number
}
}
}
ArcadeAttempt: {
payload: Prisma.$ArcadeAttemptPayload<ExtArgs>
fields: Prisma.ArcadeAttemptFieldRefs
operations: {
findUnique: {
args: Prisma.ArcadeAttemptFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload> | null
}
findUniqueOrThrow: {
args: Prisma.ArcadeAttemptFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
}
findFirst: {
args: Prisma.ArcadeAttemptFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload> | null
}
findFirstOrThrow: {
args: Prisma.ArcadeAttemptFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
}
findMany: {
args: Prisma.ArcadeAttemptFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>[]
}
create: {
args: Prisma.ArcadeAttemptCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
}
createMany: {
args: Prisma.ArcadeAttemptCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.ArcadeAttemptCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>[]
}
delete: {
args: Prisma.ArcadeAttemptDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
}
update: {
args: Prisma.ArcadeAttemptUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
}
deleteMany: {
args: Prisma.ArcadeAttemptDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.ArcadeAttemptUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.ArcadeAttemptUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>[]
}
upsert: {
args: Prisma.ArcadeAttemptUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
}
aggregate: {
args: Prisma.ArcadeAttemptAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateArcadeAttempt>
}
groupBy: {
args: Prisma.ArcadeAttemptGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ArcadeAttemptGroupByOutputType>[]
}
count: {
args: Prisma.ArcadeAttemptCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ArcadeAttemptCountAggregateOutputType> | number
}
}
}
MaterialGroup: {
payload: Prisma.$MaterialGroupPayload<ExtArgs>
fields: Prisma.MaterialGroupFieldRefs
@ -1303,6 +1531,228 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
SpacedRepetitionSet: {
payload: Prisma.$SpacedRepetitionSetPayload<ExtArgs>
fields: Prisma.SpacedRepetitionSetFieldRefs
operations: {
findUnique: {
args: Prisma.SpacedRepetitionSetFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload> | null
}
findUniqueOrThrow: {
args: Prisma.SpacedRepetitionSetFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>
}
findFirst: {
args: Prisma.SpacedRepetitionSetFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload> | null
}
findFirstOrThrow: {
args: Prisma.SpacedRepetitionSetFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>
}
findMany: {
args: Prisma.SpacedRepetitionSetFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>[]
}
create: {
args: Prisma.SpacedRepetitionSetCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>
}
createMany: {
args: Prisma.SpacedRepetitionSetCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.SpacedRepetitionSetCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>[]
}
delete: {
args: Prisma.SpacedRepetitionSetDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>
}
update: {
args: Prisma.SpacedRepetitionSetUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>
}
deleteMany: {
args: Prisma.SpacedRepetitionSetDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.SpacedRepetitionSetUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.SpacedRepetitionSetUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>[]
}
upsert: {
args: Prisma.SpacedRepetitionSetUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>
}
aggregate: {
args: Prisma.SpacedRepetitionSetAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateSpacedRepetitionSet>
}
groupBy: {
args: Prisma.SpacedRepetitionSetGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.SpacedRepetitionSetGroupByOutputType>[]
}
count: {
args: Prisma.SpacedRepetitionSetCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.SpacedRepetitionSetCountAggregateOutputType> | number
}
}
}
SpacedRepetitionSetDeck: {
payload: Prisma.$SpacedRepetitionSetDeckPayload<ExtArgs>
fields: Prisma.SpacedRepetitionSetDeckFieldRefs
operations: {
findUnique: {
args: Prisma.SpacedRepetitionSetDeckFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload> | null
}
findUniqueOrThrow: {
args: Prisma.SpacedRepetitionSetDeckFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>
}
findFirst: {
args: Prisma.SpacedRepetitionSetDeckFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload> | null
}
findFirstOrThrow: {
args: Prisma.SpacedRepetitionSetDeckFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>
}
findMany: {
args: Prisma.SpacedRepetitionSetDeckFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>[]
}
create: {
args: Prisma.SpacedRepetitionSetDeckCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>
}
createMany: {
args: Prisma.SpacedRepetitionSetDeckCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.SpacedRepetitionSetDeckCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>[]
}
delete: {
args: Prisma.SpacedRepetitionSetDeckDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>
}
update: {
args: Prisma.SpacedRepetitionSetDeckUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>
}
deleteMany: {
args: Prisma.SpacedRepetitionSetDeckDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.SpacedRepetitionSetDeckUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.SpacedRepetitionSetDeckUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>[]
}
upsert: {
args: Prisma.SpacedRepetitionSetDeckUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>
}
aggregate: {
args: Prisma.SpacedRepetitionSetDeckAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateSpacedRepetitionSetDeck>
}
groupBy: {
args: Prisma.SpacedRepetitionSetDeckGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.SpacedRepetitionSetDeckGroupByOutputType>[]
}
count: {
args: Prisma.SpacedRepetitionSetDeckCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.SpacedRepetitionSetDeckCountAggregateOutputType> | number
}
}
}
SpacedRepetitionCardState: {
payload: Prisma.$SpacedRepetitionCardStatePayload<ExtArgs>
fields: Prisma.SpacedRepetitionCardStateFieldRefs
operations: {
findUnique: {
args: Prisma.SpacedRepetitionCardStateFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload> | null
}
findUniqueOrThrow: {
args: Prisma.SpacedRepetitionCardStateFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>
}
findFirst: {
args: Prisma.SpacedRepetitionCardStateFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload> | null
}
findFirstOrThrow: {
args: Prisma.SpacedRepetitionCardStateFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>
}
findMany: {
args: Prisma.SpacedRepetitionCardStateFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>[]
}
create: {
args: Prisma.SpacedRepetitionCardStateCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>
}
createMany: {
args: Prisma.SpacedRepetitionCardStateCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.SpacedRepetitionCardStateCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>[]
}
delete: {
args: Prisma.SpacedRepetitionCardStateDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>
}
update: {
args: Prisma.SpacedRepetitionCardStateUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>
}
deleteMany: {
args: Prisma.SpacedRepetitionCardStateDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.SpacedRepetitionCardStateUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.SpacedRepetitionCardStateUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>[]
}
upsert: {
args: Prisma.SpacedRepetitionCardStateUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>
}
aggregate: {
args: Prisma.SpacedRepetitionCardStateAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateSpacedRepetitionCardState>
}
groupBy: {
args: Prisma.SpacedRepetitionCardStateGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.SpacedRepetitionCardStateGroupByOutputType>[]
}
count: {
args: Prisma.SpacedRepetitionCardStateCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.SpacedRepetitionCardStateCountAggregateOutputType> | number
}
}
}
}
} & {
other: {
@ -1470,6 +1920,52 @@ export const SettingScalarFieldEnum = {
export type SettingScalarFieldEnum = (typeof SettingScalarFieldEnum)[keyof typeof SettingScalarFieldEnum]
export const StudyActivityScalarFieldEnum = {
id: 'id',
type: 'type',
occurredAt: 'occurredAt'
} as const
export type StudyActivityScalarFieldEnum = (typeof StudyActivityScalarFieldEnum)[keyof typeof StudyActivityScalarFieldEnum]
export const ArcadePackScalarFieldEnum = {
id: 'id',
classId: 'classId',
gameType: 'gameType',
name: 'name',
description: 'description',
schemaVersion: 'schemaVersion',
sourceJson: 'sourceJson',
normalizedJson: 'normalizedJson',
validationReportJson: 'validationReportJson',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ArcadePackScalarFieldEnum = (typeof ArcadePackScalarFieldEnum)[keyof typeof ArcadePackScalarFieldEnum]
export const ArcadeAttemptScalarFieldEnum = {
id: 'id',
arcadePackId: 'arcadePackId',
mode: 'mode',
score: 'score',
maxScore: 'maxScore',
accuracy: 'accuracy',
durationSeconds: 'durationSeconds',
mistakes: 'mistakes',
hintsUsed: 'hintsUsed',
settingsJson: 'settingsJson',
resultsJson: 'resultsJson',
seed: 'seed',
completedAt: 'completedAt'
} as const
export type ArcadeAttemptScalarFieldEnum = (typeof ArcadeAttemptScalarFieldEnum)[keyof typeof ArcadeAttemptScalarFieldEnum]
export const MaterialGroupScalarFieldEnum = {
id: 'id',
classId: 'classId',
@ -1482,6 +1978,52 @@ export const MaterialGroupScalarFieldEnum = {
export type MaterialGroupScalarFieldEnum = (typeof MaterialGroupScalarFieldEnum)[keyof typeof MaterialGroupScalarFieldEnum]
export const SpacedRepetitionSetScalarFieldEnum = {
id: 'id',
classId: 'classId',
name: 'name',
description: 'description',
newCardsPerDay: 'newCardsPerDay',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type SpacedRepetitionSetScalarFieldEnum = (typeof SpacedRepetitionSetScalarFieldEnum)[keyof typeof SpacedRepetitionSetScalarFieldEnum]
export const SpacedRepetitionSetDeckScalarFieldEnum = {
setId: 'setId',
deckId: 'deckId',
sortOrder: 'sortOrder',
addedAt: 'addedAt'
} as const
export type SpacedRepetitionSetDeckScalarFieldEnum = (typeof SpacedRepetitionSetDeckScalarFieldEnum)[keyof typeof SpacedRepetitionSetDeckScalarFieldEnum]
export const SpacedRepetitionCardStateScalarFieldEnum = {
id: 'id',
setId: 'setId',
flashcardId: 'flashcardId',
due: 'due',
stability: 'stability',
difficulty: 'difficulty',
elapsedDays: 'elapsedDays',
scheduledDays: 'scheduledDays',
learningSteps: 'learningSteps',
reps: 'reps',
lapses: 'lapses',
state: 'state',
firstReviewedAt: 'firstReviewedAt',
lastReview: 'lastReview',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type SpacedRepetitionCardStateScalarFieldEnum = (typeof SpacedRepetitionCardStateScalarFieldEnum)[keyof typeof SpacedRepetitionCardStateScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
@ -1659,7 +2201,13 @@ export type GlobalOmitConfig = {
shareLink?: Prisma.ShareLinkOmit
authSecurity?: Prisma.AuthSecurityOmit
setting?: Prisma.SettingOmit
studyActivity?: Prisma.StudyActivityOmit
arcadePack?: Prisma.ArcadePackOmit
arcadeAttempt?: Prisma.ArcadeAttemptOmit
materialGroup?: Prisma.MaterialGroupOmit
spacedRepetitionSet?: Prisma.SpacedRepetitionSetOmit
spacedRepetitionSetDeck?: Prisma.SpacedRepetitionSetDeckOmit
spacedRepetitionCardState?: Prisma.SpacedRepetitionCardStateOmit
}
/* Types for Logging */

View file

@ -62,7 +62,13 @@ export const ModelName = {
ShareLink: 'ShareLink',
AuthSecurity: 'AuthSecurity',
Setting: 'Setting',
MaterialGroup: 'MaterialGroup'
StudyActivity: 'StudyActivity',
ArcadePack: 'ArcadePack',
ArcadeAttempt: 'ArcadeAttempt',
MaterialGroup: 'MaterialGroup',
SpacedRepetitionSet: 'SpacedRepetitionSet',
SpacedRepetitionSetDeck: 'SpacedRepetitionSetDeck',
SpacedRepetitionCardState: 'SpacedRepetitionCardState'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@ -209,6 +215,52 @@ export const SettingScalarFieldEnum = {
export type SettingScalarFieldEnum = (typeof SettingScalarFieldEnum)[keyof typeof SettingScalarFieldEnum]
export const StudyActivityScalarFieldEnum = {
id: 'id',
type: 'type',
occurredAt: 'occurredAt'
} as const
export type StudyActivityScalarFieldEnum = (typeof StudyActivityScalarFieldEnum)[keyof typeof StudyActivityScalarFieldEnum]
export const ArcadePackScalarFieldEnum = {
id: 'id',
classId: 'classId',
gameType: 'gameType',
name: 'name',
description: 'description',
schemaVersion: 'schemaVersion',
sourceJson: 'sourceJson',
normalizedJson: 'normalizedJson',
validationReportJson: 'validationReportJson',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ArcadePackScalarFieldEnum = (typeof ArcadePackScalarFieldEnum)[keyof typeof ArcadePackScalarFieldEnum]
export const ArcadeAttemptScalarFieldEnum = {
id: 'id',
arcadePackId: 'arcadePackId',
mode: 'mode',
score: 'score',
maxScore: 'maxScore',
accuracy: 'accuracy',
durationSeconds: 'durationSeconds',
mistakes: 'mistakes',
hintsUsed: 'hintsUsed',
settingsJson: 'settingsJson',
resultsJson: 'resultsJson',
seed: 'seed',
completedAt: 'completedAt'
} as const
export type ArcadeAttemptScalarFieldEnum = (typeof ArcadeAttemptScalarFieldEnum)[keyof typeof ArcadeAttemptScalarFieldEnum]
export const MaterialGroupScalarFieldEnum = {
id: 'id',
classId: 'classId',
@ -221,6 +273,52 @@ export const MaterialGroupScalarFieldEnum = {
export type MaterialGroupScalarFieldEnum = (typeof MaterialGroupScalarFieldEnum)[keyof typeof MaterialGroupScalarFieldEnum]
export const SpacedRepetitionSetScalarFieldEnum = {
id: 'id',
classId: 'classId',
name: 'name',
description: 'description',
newCardsPerDay: 'newCardsPerDay',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type SpacedRepetitionSetScalarFieldEnum = (typeof SpacedRepetitionSetScalarFieldEnum)[keyof typeof SpacedRepetitionSetScalarFieldEnum]
export const SpacedRepetitionSetDeckScalarFieldEnum = {
setId: 'setId',
deckId: 'deckId',
sortOrder: 'sortOrder',
addedAt: 'addedAt'
} as const
export type SpacedRepetitionSetDeckScalarFieldEnum = (typeof SpacedRepetitionSetDeckScalarFieldEnum)[keyof typeof SpacedRepetitionSetDeckScalarFieldEnum]
export const SpacedRepetitionCardStateScalarFieldEnum = {
id: 'id',
setId: 'setId',
flashcardId: 'flashcardId',
due: 'due',
stability: 'stability',
difficulty: 'difficulty',
elapsedDays: 'elapsedDays',
scheduledDays: 'scheduledDays',
learningSteps: 'learningSteps',
reps: 'reps',
lapses: 'lapses',
state: 'state',
firstReviewedAt: 'firstReviewedAt',
lastReview: 'lastReview',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type SpacedRepetitionCardStateScalarFieldEnum = (typeof SpacedRepetitionCardStateScalarFieldEnum)[keyof typeof SpacedRepetitionCardStateScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'

View file

@ -19,5 +19,11 @@ export type * from './models/QuizAttempt'
export type * from './models/ShareLink'
export type * from './models/AuthSecurity'
export type * from './models/Setting'
export type * from './models/StudyActivity'
export type * from './models/ArcadePack'
export type * from './models/ArcadeAttempt'
export type * from './models/MaterialGroup'
export type * from './models/SpacedRepetitionSet'
export type * from './models/SpacedRepetitionSetDeck'
export type * from './models/SpacedRepetitionCardState'
export type * from './commonInputTypes'

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -219,6 +219,8 @@ export type ClassWhereInput = {
decks?: Prisma.DeckListRelationFilter
quizSets?: Prisma.QuizSetListRelationFilter
materialGroups?: Prisma.MaterialGroupListRelationFilter
spacedRepetitionSets?: Prisma.SpacedRepetitionSetListRelationFilter
arcadePacks?: Prisma.ArcadePackListRelationFilter
}
export type ClassOrderByWithRelationInput = {
@ -230,6 +232,8 @@ export type ClassOrderByWithRelationInput = {
decks?: Prisma.DeckOrderByRelationAggregateInput
quizSets?: Prisma.QuizSetOrderByRelationAggregateInput
materialGroups?: Prisma.MaterialGroupOrderByRelationAggregateInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetOrderByRelationAggregateInput
arcadePacks?: Prisma.ArcadePackOrderByRelationAggregateInput
}
export type ClassWhereUniqueInput = Prisma.AtLeast<{
@ -244,6 +248,8 @@ export type ClassWhereUniqueInput = Prisma.AtLeast<{
decks?: Prisma.DeckListRelationFilter
quizSets?: Prisma.QuizSetListRelationFilter
materialGroups?: Prisma.MaterialGroupListRelationFilter
spacedRepetitionSets?: Prisma.SpacedRepetitionSetListRelationFilter
arcadePacks?: Prisma.ArcadePackListRelationFilter
}, "id" | "slug">
export type ClassOrderByWithAggregationInput = {
@ -279,6 +285,8 @@ export type ClassCreateInput = {
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateInput = {
@ -290,6 +298,8 @@ export type ClassUncheckedCreateInput = {
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
}
export type ClassUpdateInput = {
@ -301,6 +311,8 @@ export type ClassUpdateInput = {
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateInput = {
@ -312,6 +324,8 @@ export type ClassUncheckedUpdateInput = {
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateManyInput = {
@ -419,6 +433,20 @@ export type ClassUpdateOneRequiredWithoutQuizSetsNestedInput = {
update?: Prisma.XOR<Prisma.XOR<Prisma.ClassUpdateToOneWithWhereWithoutQuizSetsInput, Prisma.ClassUpdateWithoutQuizSetsInput>, Prisma.ClassUncheckedUpdateWithoutQuizSetsInput>
}
export type ClassCreateNestedOneWithoutArcadePacksInput = {
create?: Prisma.XOR<Prisma.ClassCreateWithoutArcadePacksInput, Prisma.ClassUncheckedCreateWithoutArcadePacksInput>
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutArcadePacksInput
connect?: Prisma.ClassWhereUniqueInput
}
export type ClassUpdateOneRequiredWithoutArcadePacksNestedInput = {
create?: Prisma.XOR<Prisma.ClassCreateWithoutArcadePacksInput, Prisma.ClassUncheckedCreateWithoutArcadePacksInput>
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutArcadePacksInput
upsert?: Prisma.ClassUpsertWithoutArcadePacksInput
connect?: Prisma.ClassWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.ClassUpdateToOneWithWhereWithoutArcadePacksInput, Prisma.ClassUpdateWithoutArcadePacksInput>, Prisma.ClassUncheckedUpdateWithoutArcadePacksInput>
}
export type ClassCreateNestedOneWithoutMaterialGroupsInput = {
create?: Prisma.XOR<Prisma.ClassCreateWithoutMaterialGroupsInput, Prisma.ClassUncheckedCreateWithoutMaterialGroupsInput>
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutMaterialGroupsInput
@ -433,6 +461,20 @@ export type ClassUpdateOneRequiredWithoutMaterialGroupsNestedInput = {
update?: Prisma.XOR<Prisma.XOR<Prisma.ClassUpdateToOneWithWhereWithoutMaterialGroupsInput, Prisma.ClassUpdateWithoutMaterialGroupsInput>, Prisma.ClassUncheckedUpdateWithoutMaterialGroupsInput>
}
export type ClassCreateNestedOneWithoutSpacedRepetitionSetsInput = {
create?: Prisma.XOR<Prisma.ClassCreateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedCreateWithoutSpacedRepetitionSetsInput>
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutSpacedRepetitionSetsInput
connect?: Prisma.ClassWhereUniqueInput
}
export type ClassUpdateOneRequiredWithoutSpacedRepetitionSetsNestedInput = {
create?: Prisma.XOR<Prisma.ClassCreateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedCreateWithoutSpacedRepetitionSetsInput>
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutSpacedRepetitionSetsInput
upsert?: Prisma.ClassUpsertWithoutSpacedRepetitionSetsInput
connect?: Prisma.ClassWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.ClassUpdateToOneWithWhereWithoutSpacedRepetitionSetsInput, Prisma.ClassUpdateWithoutSpacedRepetitionSetsInput>, Prisma.ClassUncheckedUpdateWithoutSpacedRepetitionSetsInput>
}
export type ClassCreateWithoutDecksInput = {
id?: string
slug: string
@ -441,6 +483,8 @@ export type ClassCreateWithoutDecksInput = {
createdAt?: Date | string
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutDecksInput = {
@ -451,6 +495,8 @@ export type ClassUncheckedCreateWithoutDecksInput = {
createdAt?: Date | string
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutDecksInput = {
@ -477,6 +523,8 @@ export type ClassUpdateWithoutDecksInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutDecksInput = {
@ -487,6 +535,8 @@ export type ClassUncheckedUpdateWithoutDecksInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateWithoutQuizSetsInput = {
@ -497,6 +547,8 @@ export type ClassCreateWithoutQuizSetsInput = {
createdAt?: Date | string
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutQuizSetsInput = {
@ -507,6 +559,8 @@ export type ClassUncheckedCreateWithoutQuizSetsInput = {
createdAt?: Date | string
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutQuizSetsInput = {
@ -533,6 +587,8 @@ export type ClassUpdateWithoutQuizSetsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutQuizSetsInput = {
@ -543,6 +599,72 @@ export type ClassUncheckedUpdateWithoutQuizSetsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateWithoutArcadePacksInput = {
id?: string
slug: string
name: string
sortOrder?: number
createdAt?: Date | string
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutArcadePacksInput = {
id?: string
slug: string
name: string
sortOrder?: number
createdAt?: Date | string
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutArcadePacksInput = {
where: Prisma.ClassWhereUniqueInput
create: Prisma.XOR<Prisma.ClassCreateWithoutArcadePacksInput, Prisma.ClassUncheckedCreateWithoutArcadePacksInput>
}
export type ClassUpsertWithoutArcadePacksInput = {
update: Prisma.XOR<Prisma.ClassUpdateWithoutArcadePacksInput, Prisma.ClassUncheckedUpdateWithoutArcadePacksInput>
create: Prisma.XOR<Prisma.ClassCreateWithoutArcadePacksInput, Prisma.ClassUncheckedCreateWithoutArcadePacksInput>
where?: Prisma.ClassWhereInput
}
export type ClassUpdateToOneWithWhereWithoutArcadePacksInput = {
where?: Prisma.ClassWhereInput
data: Prisma.XOR<Prisma.ClassUpdateWithoutArcadePacksInput, Prisma.ClassUncheckedUpdateWithoutArcadePacksInput>
}
export type ClassUpdateWithoutArcadePacksInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
slug?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutArcadePacksInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
slug?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateWithoutMaterialGroupsInput = {
@ -553,6 +675,8 @@ export type ClassCreateWithoutMaterialGroupsInput = {
createdAt?: Date | string
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutMaterialGroupsInput = {
@ -563,6 +687,8 @@ export type ClassUncheckedCreateWithoutMaterialGroupsInput = {
createdAt?: Date | string
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutMaterialGroupsInput = {
@ -589,6 +715,8 @@ export type ClassUpdateWithoutMaterialGroupsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutMaterialGroupsInput = {
@ -599,6 +727,72 @@ export type ClassUncheckedUpdateWithoutMaterialGroupsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateWithoutSpacedRepetitionSetsInput = {
id?: string
slug: string
name: string
sortOrder?: number
createdAt?: Date | string
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutSpacedRepetitionSetsInput = {
id?: string
slug: string
name: string
sortOrder?: number
createdAt?: Date | string
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutSpacedRepetitionSetsInput = {
where: Prisma.ClassWhereUniqueInput
create: Prisma.XOR<Prisma.ClassCreateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedCreateWithoutSpacedRepetitionSetsInput>
}
export type ClassUpsertWithoutSpacedRepetitionSetsInput = {
update: Prisma.XOR<Prisma.ClassUpdateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedUpdateWithoutSpacedRepetitionSetsInput>
create: Prisma.XOR<Prisma.ClassCreateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedCreateWithoutSpacedRepetitionSetsInput>
where?: Prisma.ClassWhereInput
}
export type ClassUpdateToOneWithWhereWithoutSpacedRepetitionSetsInput = {
where?: Prisma.ClassWhereInput
data: Prisma.XOR<Prisma.ClassUpdateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedUpdateWithoutSpacedRepetitionSetsInput>
}
export type ClassUpdateWithoutSpacedRepetitionSetsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
slug?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutSpacedRepetitionSetsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
slug?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
}
@ -610,12 +804,16 @@ export type ClassCountOutputType = {
decks: number
quizSets: number
materialGroups: number
spacedRepetitionSets: number
arcadePacks: number
}
export type ClassCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
decks?: boolean | ClassCountOutputTypeCountDecksArgs
quizSets?: boolean | ClassCountOutputTypeCountQuizSetsArgs
materialGroups?: boolean | ClassCountOutputTypeCountMaterialGroupsArgs
spacedRepetitionSets?: boolean | ClassCountOutputTypeCountSpacedRepetitionSetsArgs
arcadePacks?: boolean | ClassCountOutputTypeCountArcadePacksArgs
}
/**
@ -649,6 +847,20 @@ export type ClassCountOutputTypeCountMaterialGroupsArgs<ExtArgs extends runtime.
where?: Prisma.MaterialGroupWhereInput
}
/**
* ClassCountOutputType without action
*/
export type ClassCountOutputTypeCountSpacedRepetitionSetsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.SpacedRepetitionSetWhereInput
}
/**
* ClassCountOutputType without action
*/
export type ClassCountOutputTypeCountArcadePacksArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.ArcadePackWhereInput
}
export type ClassSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
@ -659,6 +871,8 @@ export type ClassSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs =
decks?: boolean | Prisma.Class$decksArgs<ExtArgs>
quizSets?: boolean | Prisma.Class$quizSetsArgs<ExtArgs>
materialGroups?: boolean | Prisma.Class$materialGroupsArgs<ExtArgs>
spacedRepetitionSets?: boolean | Prisma.Class$spacedRepetitionSetsArgs<ExtArgs>
arcadePacks?: boolean | Prisma.Class$arcadePacksArgs<ExtArgs>
_count?: boolean | Prisma.ClassCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["class"]>
@ -691,6 +905,8 @@ export type ClassInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
decks?: boolean | Prisma.Class$decksArgs<ExtArgs>
quizSets?: boolean | Prisma.Class$quizSetsArgs<ExtArgs>
materialGroups?: boolean | Prisma.Class$materialGroupsArgs<ExtArgs>
spacedRepetitionSets?: boolean | Prisma.Class$spacedRepetitionSetsArgs<ExtArgs>
arcadePacks?: boolean | Prisma.Class$arcadePacksArgs<ExtArgs>
_count?: boolean | Prisma.ClassCountOutputTypeDefaultArgs<ExtArgs>
}
export type ClassIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
@ -702,6 +918,8 @@ export type $ClassPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs
decks: Prisma.$DeckPayload<ExtArgs>[]
quizSets: Prisma.$QuizSetPayload<ExtArgs>[]
materialGroups: Prisma.$MaterialGroupPayload<ExtArgs>[]
spacedRepetitionSets: Prisma.$SpacedRepetitionSetPayload<ExtArgs>[]
arcadePacks: Prisma.$ArcadePackPayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@ -1106,6 +1324,8 @@ export interface Prisma__ClassClient<T, Null = never, ExtArgs extends runtime.Ty
decks<T extends Prisma.Class$decksArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$decksArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$DeckPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
quizSets<T extends Prisma.Class$quizSetsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$quizSetsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$QuizSetPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
materialGroups<T extends Prisma.Class$materialGroupsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$materialGroupsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$MaterialGroupPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
spacedRepetitionSets<T extends Prisma.Class$spacedRepetitionSetsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$spacedRepetitionSetsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$SpacedRepetitionSetPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
arcadePacks<T extends Prisma.Class$arcadePacksArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$arcadePacksArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$ArcadePackPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@ -1602,6 +1822,54 @@ export type Class$materialGroupsArgs<ExtArgs extends runtime.Types.Extensions.In
distinct?: Prisma.MaterialGroupScalarFieldEnum | Prisma.MaterialGroupScalarFieldEnum[]
}
/**
* Class.spacedRepetitionSets
*/
export type Class$spacedRepetitionSetsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the SpacedRepetitionSet
*/
select?: Prisma.SpacedRepetitionSetSelect<ExtArgs> | null
/**
* Omit specific fields from the SpacedRepetitionSet
*/
omit?: Prisma.SpacedRepetitionSetOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.SpacedRepetitionSetInclude<ExtArgs> | null
where?: Prisma.SpacedRepetitionSetWhereInput
orderBy?: Prisma.SpacedRepetitionSetOrderByWithRelationInput | Prisma.SpacedRepetitionSetOrderByWithRelationInput[]
cursor?: Prisma.SpacedRepetitionSetWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.SpacedRepetitionSetScalarFieldEnum | Prisma.SpacedRepetitionSetScalarFieldEnum[]
}
/**
* Class.arcadePacks
*/
export type Class$arcadePacksArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the ArcadePack
*/
select?: Prisma.ArcadePackSelect<ExtArgs> | null
/**
* Omit specific fields from the ArcadePack
*/
omit?: Prisma.ArcadePackOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.ArcadePackInclude<ExtArgs> | null
where?: Prisma.ArcadePackWhereInput
orderBy?: Prisma.ArcadePackOrderByWithRelationInput | Prisma.ArcadePackOrderByWithRelationInput[]
cursor?: Prisma.ArcadePackWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.ArcadePackScalarFieldEnum | Prisma.ArcadePackScalarFieldEnum[]
}
/**
* Class without action
*/

View file

@ -237,6 +237,7 @@ export type DeckWhereInput = {
cards?: Prisma.FlashcardListRelationFilter
shareLink?: Prisma.XOR<Prisma.ShareLinkNullableScalarRelationFilter, Prisma.ShareLinkWhereInput> | null
progress?: Prisma.StudyProgressListRelationFilter
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckListRelationFilter
}
export type DeckOrderByWithRelationInput = {
@ -252,6 +253,7 @@ export type DeckOrderByWithRelationInput = {
cards?: Prisma.FlashcardOrderByRelationAggregateInput
shareLink?: Prisma.ShareLinkOrderByWithRelationInput
progress?: Prisma.StudyProgressOrderByRelationAggregateInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckOrderByRelationAggregateInput
}
export type DeckWhereUniqueInput = Prisma.AtLeast<{
@ -270,6 +272,7 @@ export type DeckWhereUniqueInput = Prisma.AtLeast<{
cards?: Prisma.FlashcardListRelationFilter
shareLink?: Prisma.XOR<Prisma.ShareLinkNullableScalarRelationFilter, Prisma.ShareLinkWhereInput> | null
progress?: Prisma.StudyProgressListRelationFilter
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckListRelationFilter
}, "id">
export type DeckOrderByWithAggregationInput = {
@ -311,6 +314,7 @@ export type DeckCreateInput = {
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
}
export type DeckUncheckedCreateInput = {
@ -324,6 +328,7 @@ export type DeckUncheckedCreateInput = {
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
}
export type DeckUpdateInput = {
@ -337,6 +342,7 @@ export type DeckUpdateInput = {
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateInput = {
@ -350,6 +356,7 @@ export type DeckUncheckedUpdateInput = {
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
}
export type DeckCreateManyInput = {
@ -572,6 +579,20 @@ export type DeckUncheckedUpdateManyWithoutGroupNestedInput = {
deleteMany?: Prisma.DeckScalarWhereInput | Prisma.DeckScalarWhereInput[]
}
export type DeckCreateNestedOneWithoutSpacedRepetitionMembershipsInput = {
create?: Prisma.XOR<Prisma.DeckCreateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedCreateWithoutSpacedRepetitionMembershipsInput>
connectOrCreate?: Prisma.DeckCreateOrConnectWithoutSpacedRepetitionMembershipsInput
connect?: Prisma.DeckWhereUniqueInput
}
export type DeckUpdateOneRequiredWithoutSpacedRepetitionMembershipsNestedInput = {
create?: Prisma.XOR<Prisma.DeckCreateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedCreateWithoutSpacedRepetitionMembershipsInput>
connectOrCreate?: Prisma.DeckCreateOrConnectWithoutSpacedRepetitionMembershipsInput
upsert?: Prisma.DeckUpsertWithoutSpacedRepetitionMembershipsInput
connect?: Prisma.DeckWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.DeckUpdateToOneWithWhereWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUpdateWithoutSpacedRepetitionMembershipsInput>, Prisma.DeckUncheckedUpdateWithoutSpacedRepetitionMembershipsInput>
}
export type DeckCreateWithoutClassInput = {
id?: string
name: string
@ -582,6 +603,7 @@ export type DeckCreateWithoutClassInput = {
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
}
export type DeckUncheckedCreateWithoutClassInput = {
@ -594,6 +616,7 @@ export type DeckUncheckedCreateWithoutClassInput = {
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
}
export type DeckCreateOrConnectWithoutClassInput = {
@ -644,6 +667,7 @@ export type DeckCreateWithoutCardsInput = {
group?: Prisma.MaterialGroupCreateNestedOneWithoutDecksInput
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
}
export type DeckUncheckedCreateWithoutCardsInput = {
@ -656,6 +680,7 @@ export type DeckUncheckedCreateWithoutCardsInput = {
groupId?: string | null
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
}
export type DeckCreateOrConnectWithoutCardsInput = {
@ -684,6 +709,7 @@ export type DeckUpdateWithoutCardsInput = {
group?: Prisma.MaterialGroupUpdateOneWithoutDecksNestedInput
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateWithoutCardsInput = {
@ -696,6 +722,7 @@ export type DeckUncheckedUpdateWithoutCardsInput = {
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
}
export type DeckCreateWithoutProgressInput = {
@ -708,6 +735,7 @@ export type DeckCreateWithoutProgressInput = {
group?: Prisma.MaterialGroupCreateNestedOneWithoutDecksInput
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
}
export type DeckUncheckedCreateWithoutProgressInput = {
@ -720,6 +748,7 @@ export type DeckUncheckedCreateWithoutProgressInput = {
groupId?: string | null
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
}
export type DeckCreateOrConnectWithoutProgressInput = {
@ -748,6 +777,7 @@ export type DeckUpdateWithoutProgressInput = {
group?: Prisma.MaterialGroupUpdateOneWithoutDecksNestedInput
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateWithoutProgressInput = {
@ -760,6 +790,7 @@ export type DeckUncheckedUpdateWithoutProgressInput = {
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
}
export type DeckCreateWithoutShareLinkInput = {
@ -772,6 +803,7 @@ export type DeckCreateWithoutShareLinkInput = {
group?: Prisma.MaterialGroupCreateNestedOneWithoutDecksInput
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
}
export type DeckUncheckedCreateWithoutShareLinkInput = {
@ -784,6 +816,7 @@ export type DeckUncheckedCreateWithoutShareLinkInput = {
groupId?: string | null
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
}
export type DeckCreateOrConnectWithoutShareLinkInput = {
@ -812,6 +845,7 @@ export type DeckUpdateWithoutShareLinkInput = {
group?: Prisma.MaterialGroupUpdateOneWithoutDecksNestedInput
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateWithoutShareLinkInput = {
@ -824,6 +858,7 @@ export type DeckUncheckedUpdateWithoutShareLinkInput = {
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
}
export type DeckCreateWithoutGroupInput = {
@ -836,6 +871,7 @@ export type DeckCreateWithoutGroupInput = {
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
}
export type DeckUncheckedCreateWithoutGroupInput = {
@ -848,6 +884,7 @@ export type DeckUncheckedCreateWithoutGroupInput = {
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
}
export type DeckCreateOrConnectWithoutGroupInput = {
@ -875,6 +912,74 @@ export type DeckUpdateManyWithWhereWithoutGroupInput = {
data: Prisma.XOR<Prisma.DeckUpdateManyMutationInput, Prisma.DeckUncheckedUpdateManyWithoutGroupInput>
}
export type DeckCreateWithoutSpacedRepetitionMembershipsInput = {
id?: string
name: string
description?: string | null
sortOrder?: number
createdAt?: Date | string
class: Prisma.ClassCreateNestedOneWithoutDecksInput
group?: Prisma.MaterialGroupCreateNestedOneWithoutDecksInput
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
}
export type DeckUncheckedCreateWithoutSpacedRepetitionMembershipsInput = {
id?: string
classId: string
name: string
description?: string | null
sortOrder?: number
createdAt?: Date | string
groupId?: string | null
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
}
export type DeckCreateOrConnectWithoutSpacedRepetitionMembershipsInput = {
where: Prisma.DeckWhereUniqueInput
create: Prisma.XOR<Prisma.DeckCreateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedCreateWithoutSpacedRepetitionMembershipsInput>
}
export type DeckUpsertWithoutSpacedRepetitionMembershipsInput = {
update: Prisma.XOR<Prisma.DeckUpdateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedUpdateWithoutSpacedRepetitionMembershipsInput>
create: Prisma.XOR<Prisma.DeckCreateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedCreateWithoutSpacedRepetitionMembershipsInput>
where?: Prisma.DeckWhereInput
}
export type DeckUpdateToOneWithWhereWithoutSpacedRepetitionMembershipsInput = {
where?: Prisma.DeckWhereInput
data: Prisma.XOR<Prisma.DeckUpdateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedUpdateWithoutSpacedRepetitionMembershipsInput>
}
export type DeckUpdateWithoutSpacedRepetitionMembershipsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
class?: Prisma.ClassUpdateOneRequiredWithoutDecksNestedInput
group?: Prisma.MaterialGroupUpdateOneWithoutDecksNestedInput
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateWithoutSpacedRepetitionMembershipsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
classId?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
}
export type DeckCreateManyClassInput = {
id?: string
name: string
@ -894,6 +999,7 @@ export type DeckUpdateWithoutClassInput = {
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateWithoutClassInput = {
@ -906,6 +1012,7 @@ export type DeckUncheckedUpdateWithoutClassInput = {
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateManyWithoutClassInput = {
@ -936,6 +1043,7 @@ export type DeckUpdateWithoutGroupInput = {
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateWithoutGroupInput = {
@ -948,6 +1056,7 @@ export type DeckUncheckedUpdateWithoutGroupInput = {
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateManyWithoutGroupInput = {
@ -967,11 +1076,13 @@ export type DeckUncheckedUpdateManyWithoutGroupInput = {
export type DeckCountOutputType = {
cards: number
progress: number
spacedRepetitionMemberships: number
}
export type DeckCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
cards?: boolean | DeckCountOutputTypeCountCardsArgs
progress?: boolean | DeckCountOutputTypeCountProgressArgs
spacedRepetitionMemberships?: boolean | DeckCountOutputTypeCountSpacedRepetitionMembershipsArgs
}
/**
@ -998,6 +1109,13 @@ export type DeckCountOutputTypeCountProgressArgs<ExtArgs extends runtime.Types.E
where?: Prisma.StudyProgressWhereInput
}
/**
* DeckCountOutputType without action
*/
export type DeckCountOutputTypeCountSpacedRepetitionMembershipsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.SpacedRepetitionSetDeckWhereInput
}
export type DeckSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
@ -1012,6 +1130,7 @@ export type DeckSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
cards?: boolean | Prisma.Deck$cardsArgs<ExtArgs>
shareLink?: boolean | Prisma.Deck$shareLinkArgs<ExtArgs>
progress?: boolean | Prisma.Deck$progressArgs<ExtArgs>
spacedRepetitionMemberships?: boolean | Prisma.Deck$spacedRepetitionMembershipsArgs<ExtArgs>
_count?: boolean | Prisma.DeckCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["deck"]>
@ -1056,6 +1175,7 @@ export type DeckInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
cards?: boolean | Prisma.Deck$cardsArgs<ExtArgs>
shareLink?: boolean | Prisma.Deck$shareLinkArgs<ExtArgs>
progress?: boolean | Prisma.Deck$progressArgs<ExtArgs>
spacedRepetitionMemberships?: boolean | Prisma.Deck$spacedRepetitionMembershipsArgs<ExtArgs>
_count?: boolean | Prisma.DeckCountOutputTypeDefaultArgs<ExtArgs>
}
export type DeckIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@ -1075,6 +1195,7 @@ export type $DeckPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
cards: Prisma.$FlashcardPayload<ExtArgs>[]
shareLink: Prisma.$ShareLinkPayload<ExtArgs> | null
progress: Prisma.$StudyProgressPayload<ExtArgs>[]
spacedRepetitionMemberships: Prisma.$SpacedRepetitionSetDeckPayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@ -1483,6 +1604,7 @@ export interface Prisma__DeckClient<T, Null = never, ExtArgs extends runtime.Typ
cards<T extends Prisma.Deck$cardsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Deck$cardsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$FlashcardPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
shareLink<T extends Prisma.Deck$shareLinkArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Deck$shareLinkArgs<ExtArgs>>): Prisma.Prisma__ShareLinkClient<runtime.Types.Result.GetResult<Prisma.$ShareLinkPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
progress<T extends Prisma.Deck$progressArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Deck$progressArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$StudyProgressPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
spacedRepetitionMemberships<T extends Prisma.Deck$spacedRepetitionMembershipsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Deck$spacedRepetitionMembershipsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$SpacedRepetitionSetDeckPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@ -2003,6 +2125,30 @@ export type Deck$progressArgs<ExtArgs extends runtime.Types.Extensions.InternalA
distinct?: Prisma.StudyProgressScalarFieldEnum | Prisma.StudyProgressScalarFieldEnum[]
}
/**
* Deck.spacedRepetitionMemberships
*/
export type Deck$spacedRepetitionMembershipsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the SpacedRepetitionSetDeck
*/
select?: Prisma.SpacedRepetitionSetDeckSelect<ExtArgs> | null
/**
* Omit specific fields from the SpacedRepetitionSetDeck
*/
omit?: Prisma.SpacedRepetitionSetDeckOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.SpacedRepetitionSetDeckInclude<ExtArgs> | null
where?: Prisma.SpacedRepetitionSetDeckWhereInput
orderBy?: Prisma.SpacedRepetitionSetDeckOrderByWithRelationInput | Prisma.SpacedRepetitionSetDeckOrderByWithRelationInput[]
cursor?: Prisma.SpacedRepetitionSetDeckWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.SpacedRepetitionSetDeckScalarFieldEnum | Prisma.SpacedRepetitionSetDeckScalarFieldEnum[]
}
/**
* Deck without action
*/

View file

@ -217,6 +217,7 @@ export type FlashcardWhereInput = {
back?: Prisma.StringFilter<"Flashcard"> | string
sortOrder?: Prisma.IntFilter<"Flashcard"> | number
deck?: Prisma.XOR<Prisma.DeckScalarRelationFilter, Prisma.DeckWhereInput>
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateListRelationFilter
}
export type FlashcardOrderByWithRelationInput = {
@ -226,6 +227,7 @@ export type FlashcardOrderByWithRelationInput = {
back?: Prisma.SortOrder
sortOrder?: Prisma.SortOrder
deck?: Prisma.DeckOrderByWithRelationInput
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateOrderByRelationAggregateInput
}
export type FlashcardWhereUniqueInput = Prisma.AtLeast<{
@ -238,6 +240,7 @@ export type FlashcardWhereUniqueInput = Prisma.AtLeast<{
back?: Prisma.StringFilter<"Flashcard"> | string
sortOrder?: Prisma.IntFilter<"Flashcard"> | number
deck?: Prisma.XOR<Prisma.DeckScalarRelationFilter, Prisma.DeckWhereInput>
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateListRelationFilter
}, "id">
export type FlashcardOrderByWithAggregationInput = {
@ -270,6 +273,7 @@ export type FlashcardCreateInput = {
back: string
sortOrder?: number
deck: Prisma.DeckCreateNestedOneWithoutCardsInput
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateCreateNestedManyWithoutFlashcardInput
}
export type FlashcardUncheckedCreateInput = {
@ -278,6 +282,7 @@ export type FlashcardUncheckedCreateInput = {
front: string
back: string
sortOrder?: number
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUncheckedCreateNestedManyWithoutFlashcardInput
}
export type FlashcardUpdateInput = {
@ -286,6 +291,7 @@ export type FlashcardUpdateInput = {
back?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
deck?: Prisma.DeckUpdateOneRequiredWithoutCardsNestedInput
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUpdateManyWithoutFlashcardNestedInput
}
export type FlashcardUncheckedUpdateInput = {
@ -294,6 +300,7 @@ export type FlashcardUncheckedUpdateInput = {
front?: Prisma.StringFieldUpdateOperationsInput | string
back?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUncheckedUpdateManyWithoutFlashcardNestedInput
}
export type FlashcardCreateManyInput = {
@ -361,6 +368,11 @@ export type FlashcardSumOrderByAggregateInput = {
sortOrder?: Prisma.SortOrder
}
export type FlashcardScalarRelationFilter = {
is?: Prisma.FlashcardWhereInput
isNot?: Prisma.FlashcardWhereInput
}
export type FlashcardCreateNestedManyWithoutDeckInput = {
create?: Prisma.XOR<Prisma.FlashcardCreateWithoutDeckInput, Prisma.FlashcardUncheckedCreateWithoutDeckInput> | Prisma.FlashcardCreateWithoutDeckInput[] | Prisma.FlashcardUncheckedCreateWithoutDeckInput[]
connectOrCreate?: Prisma.FlashcardCreateOrConnectWithoutDeckInput | Prisma.FlashcardCreateOrConnectWithoutDeckInput[]
@ -403,11 +415,26 @@ export type FlashcardUncheckedUpdateManyWithoutDeckNestedInput = {
deleteMany?: Prisma.FlashcardScalarWhereInput | Prisma.FlashcardScalarWhereInput[]
}
export type FlashcardCreateNestedOneWithoutSpacedRepetitionStatesInput = {
create?: Prisma.XOR<Prisma.FlashcardCreateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedCreateWithoutSpacedRepetitionStatesInput>
connectOrCreate?: Prisma.FlashcardCreateOrConnectWithoutSpacedRepetitionStatesInput
connect?: Prisma.FlashcardWhereUniqueInput
}
export type FlashcardUpdateOneRequiredWithoutSpacedRepetitionStatesNestedInput = {
create?: Prisma.XOR<Prisma.FlashcardCreateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedCreateWithoutSpacedRepetitionStatesInput>
connectOrCreate?: Prisma.FlashcardCreateOrConnectWithoutSpacedRepetitionStatesInput
upsert?: Prisma.FlashcardUpsertWithoutSpacedRepetitionStatesInput
connect?: Prisma.FlashcardWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.FlashcardUpdateToOneWithWhereWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUpdateWithoutSpacedRepetitionStatesInput>, Prisma.FlashcardUncheckedUpdateWithoutSpacedRepetitionStatesInput>
}
export type FlashcardCreateWithoutDeckInput = {
id?: string
front: string
back: string
sortOrder?: number
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateCreateNestedManyWithoutFlashcardInput
}
export type FlashcardUncheckedCreateWithoutDeckInput = {
@ -415,6 +442,7 @@ export type FlashcardUncheckedCreateWithoutDeckInput = {
front: string
back: string
sortOrder?: number
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUncheckedCreateNestedManyWithoutFlashcardInput
}
export type FlashcardCreateOrConnectWithoutDeckInput = {
@ -453,6 +481,54 @@ export type FlashcardScalarWhereInput = {
sortOrder?: Prisma.IntFilter<"Flashcard"> | number
}
export type FlashcardCreateWithoutSpacedRepetitionStatesInput = {
id?: string
front: string
back: string
sortOrder?: number
deck: Prisma.DeckCreateNestedOneWithoutCardsInput
}
export type FlashcardUncheckedCreateWithoutSpacedRepetitionStatesInput = {
id?: string
deckId: string
front: string
back: string
sortOrder?: number
}
export type FlashcardCreateOrConnectWithoutSpacedRepetitionStatesInput = {
where: Prisma.FlashcardWhereUniqueInput
create: Prisma.XOR<Prisma.FlashcardCreateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedCreateWithoutSpacedRepetitionStatesInput>
}
export type FlashcardUpsertWithoutSpacedRepetitionStatesInput = {
update: Prisma.XOR<Prisma.FlashcardUpdateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedUpdateWithoutSpacedRepetitionStatesInput>
create: Prisma.XOR<Prisma.FlashcardCreateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedCreateWithoutSpacedRepetitionStatesInput>
where?: Prisma.FlashcardWhereInput
}
export type FlashcardUpdateToOneWithWhereWithoutSpacedRepetitionStatesInput = {
where?: Prisma.FlashcardWhereInput
data: Prisma.XOR<Prisma.FlashcardUpdateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedUpdateWithoutSpacedRepetitionStatesInput>
}
export type FlashcardUpdateWithoutSpacedRepetitionStatesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
front?: Prisma.StringFieldUpdateOperationsInput | string
back?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
deck?: Prisma.DeckUpdateOneRequiredWithoutCardsNestedInput
}
export type FlashcardUncheckedUpdateWithoutSpacedRepetitionStatesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
deckId?: Prisma.StringFieldUpdateOperationsInput | string
front?: Prisma.StringFieldUpdateOperationsInput | string
back?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
}
export type FlashcardCreateManyDeckInput = {
id?: string
front: string
@ -465,6 +541,7 @@ export type FlashcardUpdateWithoutDeckInput = {
front?: Prisma.StringFieldUpdateOperationsInput | string
back?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUpdateManyWithoutFlashcardNestedInput
}
export type FlashcardUncheckedUpdateWithoutDeckInput = {
@ -472,6 +549,7 @@ export type FlashcardUncheckedUpdateWithoutDeckInput = {
front?: Prisma.StringFieldUpdateOperationsInput | string
back?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUncheckedUpdateManyWithoutFlashcardNestedInput
}
export type FlashcardUncheckedUpdateManyWithoutDeckInput = {
@ -482,6 +560,35 @@ export type FlashcardUncheckedUpdateManyWithoutDeckInput = {
}
/**
* Count Type FlashcardCountOutputType
*/
export type FlashcardCountOutputType = {
spacedRepetitionStates: number
}
export type FlashcardCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
spacedRepetitionStates?: boolean | FlashcardCountOutputTypeCountSpacedRepetitionStatesArgs
}
/**
* FlashcardCountOutputType without action
*/
export type FlashcardCountOutputTypeDefaultArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the FlashcardCountOutputType
*/
select?: Prisma.FlashcardCountOutputTypeSelect<ExtArgs> | null
}
/**
* FlashcardCountOutputType without action
*/
export type FlashcardCountOutputTypeCountSpacedRepetitionStatesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.SpacedRepetitionCardStateWhereInput
}
export type FlashcardSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
@ -490,6 +597,8 @@ export type FlashcardSelect<ExtArgs extends runtime.Types.Extensions.InternalArg
back?: boolean
sortOrder?: boolean
deck?: boolean | Prisma.DeckDefaultArgs<ExtArgs>
spacedRepetitionStates?: boolean | Prisma.Flashcard$spacedRepetitionStatesArgs<ExtArgs>
_count?: boolean | Prisma.FlashcardCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["flashcard"]>
export type FlashcardSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
@ -521,6 +630,8 @@ export type FlashcardSelectScalar = {
export type FlashcardOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "deckId" | "front" | "back" | "sortOrder", ExtArgs["result"]["flashcard"]>
export type FlashcardInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
deck?: boolean | Prisma.DeckDefaultArgs<ExtArgs>
spacedRepetitionStates?: boolean | Prisma.Flashcard$spacedRepetitionStatesArgs<ExtArgs>
_count?: boolean | Prisma.FlashcardCountOutputTypeDefaultArgs<ExtArgs>
}
export type FlashcardIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
deck?: boolean | Prisma.DeckDefaultArgs<ExtArgs>
@ -533,6 +644,7 @@ export type $FlashcardPayload<ExtArgs extends runtime.Types.Extensions.InternalA
name: "Flashcard"
objects: {
deck: Prisma.$DeckPayload<ExtArgs>
spacedRepetitionStates: Prisma.$SpacedRepetitionCardStatePayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@ -935,6 +1047,7 @@ readonly fields: FlashcardFieldRefs;
export interface Prisma__FlashcardClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
readonly [Symbol.toStringTag]: "PrismaPromise"
deck<T extends Prisma.DeckDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.DeckDefaultArgs<ExtArgs>>): Prisma.Prisma__DeckClient<runtime.Types.Result.GetResult<Prisma.$DeckPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
spacedRepetitionStates<T extends Prisma.Flashcard$spacedRepetitionStatesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Flashcard$spacedRepetitionStatesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$SpacedRepetitionCardStatePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@ -1367,6 +1480,30 @@ export type FlashcardDeleteManyArgs<ExtArgs extends runtime.Types.Extensions.Int
limit?: number
}
/**
* Flashcard.spacedRepetitionStates
*/
export type Flashcard$spacedRepetitionStatesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the SpacedRepetitionCardState
*/
select?: Prisma.SpacedRepetitionCardStateSelect<ExtArgs> | null
/**
* Omit specific fields from the SpacedRepetitionCardState
*/
omit?: Prisma.SpacedRepetitionCardStateOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.SpacedRepetitionCardStateInclude<ExtArgs> | null
where?: Prisma.SpacedRepetitionCardStateWhereInput
orderBy?: Prisma.SpacedRepetitionCardStateOrderByWithRelationInput | Prisma.SpacedRepetitionCardStateOrderByWithRelationInput[]
cursor?: Prisma.SpacedRepetitionCardStateWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.SpacedRepetitionCardStateScalarFieldEnum | Prisma.SpacedRepetitionCardStateScalarFieldEnum[]
}
/**
* Flashcard without action
*/

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,9 @@
import type { StudyActivityType } from "@/lib/validation/activitySchemas";
export function recordStudyActivity(type: StudyActivityType) {
return fetch("/api/activity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type }),
});
}

View file

@ -0,0 +1,5 @@
export class ArcadeImportError extends Error {
constructor(message: string, public readonly details: string[] = []) {
super(message);
}
}

View file

@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { deterministicShuffle, replayConnectionsAttempt } from "@/lib/arcade/connectionsEngine";
import type { NormalizedConnectionsPack } from "@/types/arcade";
const pack: NormalizedConnectionsPack = {
schemaVersion: 1,
type: "connections",
name: "Test",
settings: { groupCount: 4, itemsPerGroup: 4, allowedMistakes: 4 },
content: Array.from({ length: 4 }, (_, groupIndex) => ({
id: `group-${groupIndex + 1}`,
category: `Category ${groupIndex + 1}`,
explanation: `Explanation ${groupIndex + 1}`,
items: Array.from({ length: 4 }, (_, itemIndex) => ({
id: `g${groupIndex + 1}-i${itemIndex + 1}`,
text: `Item ${groupIndex + 1}-${itemIndex + 1}`,
})),
})),
};
const settings = { allowedMistakes: 4, oneAwayFeedback: true };
describe("Connections engine", () => {
it("shuffles deterministically for a seed", () => {
const items = Array.from({ length: 16 }, (_, index) => index);
expect(deterministicShuffle(items, "round-1")).toEqual(deterministicShuffle(items, "round-1"));
expect(deterministicShuffle(items, "round-1")).not.toEqual(deterministicShuffle(items, "round-2"));
});
it("scores a perfect completed round", () => {
const submissions = pack.content.map((group, index) => ({
itemIds: group.items.map((item) => item.id),
elapsedMs: (index + 1) * 1000,
}));
const replay = replayConnectionsAttempt(pack, submissions, settings, 12);
expect(replay.complete).toBe(true);
expect(replay.result).toMatchObject({ outcome: "WON", score: 400, mistakes: 0, accuracy: 1 });
});
it("deducts mistakes and detects one-away selections", () => {
const wrong = [
...pack.content[0].items.slice(0, 3).map((item) => item.id),
pack.content[1].items[0].id,
];
const submissions = [
{ itemIds: wrong, elapsedMs: 1000 },
...pack.content.map((group, index) => ({ itemIds: group.items.map((item) => item.id), elapsedMs: (index + 2) * 1000 })),
];
const replay = replayConnectionsAttempt(pack, submissions, settings, 20);
expect(replay.result).toMatchObject({ outcome: "WON", score: 390, mistakes: 1, hintsUsed: 1, accuracy: 0.8 });
});
it("ends after the configured number of mistakes", () => {
const wrong = [pack.content[0].items[0].id, pack.content[1].items[0].id, pack.content[2].items[0].id, pack.content[3].items[0].id];
const replay = replayConnectionsAttempt(pack, Array.from({ length: 4 }, (_, index) => ({ itemIds: wrong, elapsedMs: index * 1000 })), settings, 10);
expect(replay.complete).toBe(true);
expect(replay.result).toMatchObject({ outcome: "LOST", score: 0, mistakes: 4 });
});
it("rejects selections containing unknown item ids", () => {
expect(() => replayConnectionsAttempt(pack, [{ itemIds: ["missing", "a", "b", "c"], elapsedMs: 0 }], settings, 0)).toThrow("invalid tile selection");
});
});

View file

@ -0,0 +1,110 @@
import type {
ArcadeRoundResult,
ArcadeSessionSettings,
ConnectionsSubmission,
NormalizedConnectionsGroup,
NormalizedConnectionsPack,
} from "@/types/arcade";
function hashSeed(seed: string) {
let value = 2166136261;
for (let index = 0; index < seed.length; index += 1) {
value ^= seed.charCodeAt(index);
value = Math.imul(value, 16777619);
}
return value >>> 0;
}
function seededRandom(seed: string) {
let state = hashSeed(seed);
return () => {
state += 0x6d2b79f5;
let value = state;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
}
export function deterministicShuffle<T>(items: readonly T[], seed: string): T[] {
const result = [...items];
const random = seededRandom(seed);
for (let index = result.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(random() * (index + 1));
[result[index], result[swapIndex]] = [result[swapIndex], result[index]];
}
return result;
}
function groupForSelection(groups: NormalizedConnectionsGroup[], itemIds: string[]) {
const selected = new Set(itemIds);
return groups.find(
(group) => group.items.length === selected.size && group.items.every((item) => selected.has(item.id))
);
}
export function isOneAway(
groups: NormalizedConnectionsGroup[],
selectedItemIds: string[],
solvedGroupIds: Set<string>
) {
const selected = new Set(selectedItemIds);
return groups.some(
(group) =>
!solvedGroupIds.has(group.id) &&
group.items.filter((item) => selected.has(item.id)).length === 3
);
}
export function replayConnectionsAttempt(
pack: NormalizedConnectionsPack,
submissions: ConnectionsSubmission[],
settings: ArcadeSessionSettings,
durationSeconds: number
): { complete: boolean; result: ArcadeRoundResult } {
const validItemIds = new Set(pack.content.flatMap((group) => group.items.map((item) => item.id)));
const solvedGroupIds = new Set<string>();
const incorrectSelections: string[][] = [];
let hintsUsed = 0;
let mistakes = 0;
let processed = 0;
for (const submission of submissions) {
if (solvedGroupIds.size === pack.content.length || mistakes >= settings.allowedMistakes) break;
if (new Set(submission.itemIds).size !== 4 || submission.itemIds.some((id) => !validItemIds.has(id))) {
throw new Error("An attempt contains an invalid tile selection.");
}
processed += 1;
const group = groupForSelection(pack.content, submission.itemIds);
if (group && !solvedGroupIds.has(group.id)) {
solvedGroupIds.add(group.id);
} else {
if (settings.oneAwayFeedback && isOneAway(pack.content, submission.itemIds, solvedGroupIds)) {
hintsUsed += 1;
}
mistakes += 1;
incorrectSelections.push([...submission.itemIds]);
}
}
const complete = solvedGroupIds.size === pack.content.length || mistakes >= settings.allowedMistakes;
const score = Math.max(0, solvedGroupIds.size * 100 - mistakes * 10);
const result: ArcadeRoundResult = {
outcome: solvedGroupIds.size === pack.content.length ? "WON" : "LOST",
score,
maxScore: 400,
accuracy: processed === 0 ? 0 : solvedGroupIds.size / processed,
durationSeconds,
mistakes,
hintsUsed,
groups: pack.content.map((group) => ({
groupId: group.id,
category: group.category,
items: group.items.map((item) => item.text),
explanation: group.explanation,
solved: solvedGroupIds.has(group.id),
})),
incorrectSelections,
};
return { complete, result };
}

View file

@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { ArcadeImportError, parseConnectionsImport, parseConnectionsImportBatch } from "@/lib/arcade/connectionsImport";
function validPack() {
return {
schemaVersion: 1,
type: "connections",
name: "Medication Connections",
description: "Four medication groups",
settings: { groupCount: 4, itemsPerGroup: 4, allowedMistakes: 4 },
content: Array.from({ length: 4 }, (_, groupIndex) => ({
category: `Category ${groupIndex + 1}`,
items: Array.from({ length: 4 }, (_, itemIndex) => `Item ${groupIndex + 1}-${itemIndex + 1}`),
explanation: `Explanation ${groupIndex + 1}`,
})),
};
}
describe("Connections imports", () => {
it("normalizes a valid board and creates stable ids", () => {
const result = parseConnectionsImport(JSON.stringify(validPack()));
expect(result.preview.itemCount).toBe(16);
expect(result.preview.itemCount).toBe(16);
expect(result.preview.normalized.content[0].id).toBe("group-1");
expect(result.preview.normalized.content[0].items[0].id).toBe("group-1-item-1");
});
it("rejects fences and surrounding prose", () => {
expect(() => parseConnectionsImport(`\`\`\`json\n${JSON.stringify(validPack())}\n\`\`\``)).toThrow(ArcadeImportError);
expect(() => parseConnectionsImport(`Here is the pack: ${JSON.stringify(validPack())}`)).toThrow(ArcadeImportError);
});
it("rejects duplicate tiles case-insensitively", () => {
const pack = validPack();
pack.content[1].items[0] = " item 1-1 ";
expect(() => parseConnectionsImport(JSON.stringify(pack))).toThrow("Every tile must be unique");
});
it("rejects unknown keys", () => {
const pack = { ...validPack(), extra: true };
expect(() => parseConnectionsImport(JSON.stringify(pack))).toThrow("does not match schema version 1");
});
it("reports long-tile warnings without changing the content", () => {
const pack = validPack();
pack.content[0].items[0] = "This intentionally long tile contains too many words";
const result = parseConnectionsImport(JSON.stringify(pack));
expect(result.report.warnings.length).toBeGreaterThan(0);
expect(result.preview.normalized.content[0].items[0].text).toBe(pack.content[0].items[0]);
});
it("does not treat a shared generic word as a similar category", () => {
const pack = validPack();
pack.content[0].category = "ACE Inhibitors";
pack.content[1].category = "Proton Pump Inhibitors";
const result = parseConnectionsImport(JSON.stringify(pack));
expect(result.report.warnings).not.toContain(
"Categories “ACE Inhibitors” and “Proton Pump Inhibitors” may be too similar."
);
});
it("previews multiple packs from one JSON array", () => {
const first = validPack();
const second = { ...validPack(), name: "Second board" };
const result = parseConnectionsImportBatch(JSON.stringify([first, second]));
expect(result.count).toBe(2);
expect(result.packs.map((pack) => pack.name)).toEqual(["Medication Connections", "Second board"]);
});
it("limits batch imports to ten packs", () => {
const batch = Array.from({ length: 11 }, () => validPack());
expect(() => parseConnectionsImportBatch(JSON.stringify(batch))).toThrow("between 1 and 10");
});
});

View file

@ -0,0 +1,168 @@
import { parseAndRepairJson } from "@/lib/jsonRepair";
import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
import { connectionsImportSchema } from "@/lib/validation/arcadeSchemas";
import type {
ArcadeImportPreview,
ArcadeImportBatchPreview,
ArcadeValidationReport,
NormalizedConnectionsPack,
} from "@/types/arcade";
export { ArcadeImportError } from "@/lib/arcade/arcadeImport";
function clean(value: string) {
return value.trim().replace(/\s+/g, " ");
}
function comparisonKey(value: string) {
return clean(value).toLocaleLowerCase();
}
function safeId(value: string, fallback: string) {
const id = value
.trim()
.toLocaleLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
return id || fallback;
}
function findWarnings(pack: NormalizedConnectionsPack) {
const warnings: string[] = [];
const categoryKeys = pack.content.map((group) => comparisonKey(group.category));
for (const group of pack.content) {
for (const item of group.items) {
if (item.text.length > 40) {
warnings.push(`${item.text}” is longer than 40 characters and may be difficult to scan.`);
} else if (item.text.split(/\s+/).length > 4) {
warnings.push(`${item.text}” contains more than four words.`);
}
}
}
for (let left = 0; left < categoryKeys.length; left += 1) {
for (let right = left + 1; right < categoryKeys.length; right += 1) {
const a = categoryKeys[left];
const b = categoryKeys[right];
if (a.includes(b) || b.includes(a)) {
warnings.push(
`Categories “${pack.content[left].category}” and “${pack.content[right].category}” may be too similar.`
);
}
}
}
return [...new Set(warnings)];
}
export function parseConnectionsImportBatch(rawJson: string): ArcadeImportBatchPreview<NormalizedConnectionsPack> {
const trimmed = rawJson.trim();
const isObject = trimmed.startsWith("{") && trimmed.endsWith("}");
const isArray = trimmed.startsWith("[") && trimmed.endsWith("]");
if (trimmed.includes("```") || (!isObject && !isArray)) {
throw new ArcadeImportError("Paste one raw JSON object or an array of pack objects without Markdown fences or surrounding prose.");
}
const repaired = parseAndRepairJson(trimmed);
if (!repaired.success) {
throw new ArcadeImportError(repaired.error ?? "The JSON could not be parsed.");
}
const values = Array.isArray(repaired.data) ? repaired.data : [repaired.data];
if (values.length < 1 || values.length > 10) {
throw new ArcadeImportError("Import between 1 and 10 Connections packs at a time.");
}
const packs = values.map((value, index) => {
try {
const parsed = parseConnectionsImport(JSON.stringify(value));
return {
...parsed.preview,
wasRepaired: parsed.preview.wasRepaired || repaired.wasRepaired,
};
} catch (error) {
if (error instanceof ArcadeImportError) {
throw new ArcadeImportError(`Pack ${index + 1}: ${error.message}`, error.details);
}
throw error;
}
});
return { packs, count: packs.length, wasRepaired: repaired.wasRepaired };
}
export function parseConnectionsImport(rawJson: string): {
preview: ArcadeImportPreview<NormalizedConnectionsPack>;
report: ArcadeValidationReport;
} {
const trimmed = rawJson.trim();
if (trimmed.includes("```") || !trimmed.startsWith("{") || !trimmed.endsWith("}")) {
throw new ArcadeImportError("Paste one raw JSON object without Markdown fences or surrounding prose.");
}
const repaired = parseAndRepairJson(trimmed);
if (!repaired.success) {
throw new ArcadeImportError(repaired.error ?? "The JSON could not be parsed.");
}
const parsed = connectionsImportSchema.safeParse(repaired.data);
if (!parsed.success) {
throw new ArcadeImportError(
"The Connections pack does not match schema version 1.",
parsed.error.issues.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`)
);
}
const categoryKeys = parsed.data.content.map((group) => comparisonKey(group.category));
if (new Set(categoryKeys).size !== categoryKeys.length) {
throw new ArcadeImportError("Every group must have a unique category name.");
}
const itemKeys = parsed.data.content.flatMap((group) => group.items.map(comparisonKey));
if (new Set(itemKeys).size !== itemKeys.length) {
throw new ArcadeImportError("Every tile must be unique across the entire board.");
}
const usedGroupIds = new Set<string>();
const normalized: NormalizedConnectionsPack = {
schemaVersion: 1,
type: "connections",
name: clean(parsed.data.name),
description: parsed.data.description ? clean(parsed.data.description) : undefined,
settings: { ...parsed.data.settings },
content: parsed.data.content.map((group, groupIndex) => {
const fallbackId = `group-${groupIndex + 1}`;
const groupId = safeId(group.id ?? fallbackId, fallbackId);
if (usedGroupIds.has(groupId)) {
throw new ArcadeImportError(`Group id “${groupId}” is duplicated.`);
}
usedGroupIds.add(groupId);
return {
id: groupId,
category: clean(group.category),
explanation: group.explanation.trim(),
items: group.items.map((text, itemIndex) => ({
id: `${groupId}-item-${itemIndex + 1}`,
text: clean(text),
})),
};
}),
};
const warnings = findWarnings(normalized);
const report = { wasRepaired: repaired.wasRepaired, warnings };
return {
report,
preview: {
gameType: "connections",
name: normalized.name,
description: normalized.description,
categories: normalized.content.map((group) => group.category),
itemCount: 16,
wasRepaired: repaired.wasRepaired,
warnings,
normalized,
source: repaired.data,
},
};
}

View file

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { replayCrosswordAttempt } from "@/lib/arcade/crosswordAttempt";
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
import { crosswordTestPack } from "@/lib/arcade/crosswordTestData";
const settings = { size: "mini" as const, instantCheck: false, allowHints: true };
describe("Crossword attempt replay", () => {
it("scores a perfect unassisted puzzle on the server", () => {
const pack = crosswordTestPack();
const layout = generateCrosswordLayout(pack, "mini", "perfect");
const answers = Object.fromEntries(layout.entries.map((entry) => [entry.id, entry.answer]));
const result = replayCrosswordAttempt(pack, "perfect", settings, answers, [], 120, false);
expect(result.score).toBe(result.maxScore);
expect(result.accuracy).toBe(1);
expect(result.placedCount + result.omittedCount).toBe(80);
});
it("applies check and reveal penalties and rejects tampered actions", () => {
const pack = crosswordTestPack();
const layout = generateCrosswordLayout(pack, "mini", "assisted");
const entry = layout.entries[0];
const answers = Object.fromEntries(layout.entries.map((item) => [item.id, item.answer]));
const result = replayCrosswordAttempt(pack, "assisted", settings, answers, [
{ type: "CHECK_WORD", entryId: entry.id, value: "WRONG", elapsedMs: 1000 },
{ type: "REVEAL_LETTER", cellKey: entry.cellKeys[0], elapsedMs: 2000 },
{ type: "ALTERNATE_CLUE", entryId: entry.id, elapsedMs: 3000 },
], 30, false);
const assistedWords = layout.cells.find((cell) => cell.key === entry.cellKeys[0])?.entryIds.length ?? 1;
expect(result.score).toBe(result.maxScore - 5 - assistedWords * 10);
expect(result.hintsUsed).toBe(2);
expect(() => replayCrosswordAttempt(pack, "assisted", settings, answers, [{ type: "REVEAL_LETTER", cellKey: "999:999", elapsedMs: 1 }], 1, false)).toThrow("invalid letter reveal");
});
});

View file

@ -0,0 +1,116 @@
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
import type {
CrosswordAction,
CrosswordRoundResult,
CrosswordSessionSettings,
NormalizedCrosswordPack,
} from "@/types/arcade";
function answerKey(value: string) {
return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^A-Za-z]/g, "").toUpperCase();
}
export function replayCrosswordAttempt(
pack: NormalizedCrosswordPack,
seed: string,
settings: CrosswordSessionSettings,
finalAnswers: Record<string, string>,
actions: CrosswordAction[],
durationSeconds: number,
gaveUp: boolean
): CrosswordRoundResult {
const layout = generateCrosswordLayout(pack, settings.size, seed);
const entryById = new Map(layout.entries.map((entry) => [entry.id, entry]));
const cellByKey = new Map(layout.cells.map((cell) => [cell.key, cell]));
const unknownAnswerId = Object.keys(finalAnswers).find((id) => !entryById.has(id));
if (unknownAnswerId) throw new Error("An attempt contains an answer for an entry outside this layout.");
const revealedCells = new Set<string>();
const revealedWords = new Set<string>();
const alternateClues = new Set<string>();
let incorrectChecks = 0;
let hintsUsed = 0;
for (const action of actions) {
if (!Number.isInteger(action.elapsedMs) || action.elapsedMs < 0 || action.elapsedMs > 86_400_000) {
throw new Error("An attempt contains an invalid action timestamp.");
}
if (action.type === "REVEAL_LETTER") {
if (!settings.allowHints || !cellByKey.has(action.cellKey)) throw new Error("An attempt contains an invalid letter reveal.");
if (!revealedCells.has(action.cellKey)) hintsUsed += 1;
revealedCells.add(action.cellKey);
continue;
}
if (action.type === "CHECK_PUZZLE") {
if (Object.keys(action.answers).some((id) => !entryById.has(id))) throw new Error("A puzzle check references an unknown entry.");
continue;
}
const entry = entryById.get(action.entryId);
if (!entry) throw new Error("An attempt action references an unknown entry.");
if (action.type === "CHECK_WORD") {
if (answerKey(action.value) !== entry.answer) incorrectChecks += 1;
} else if (action.type === "ALTERNATE_CLUE") {
if (!settings.allowHints) throw new Error("Hints were disabled for this attempt.");
if (!alternateClues.has(entry.id)) hintsUsed += 1;
alternateClues.add(entry.id);
} else if (action.type === "REVEAL_WORD") {
if (!settings.allowHints) throw new Error("Hints were disabled for this attempt.");
if (!revealedWords.has(entry.id)) hintsUsed += 1;
revealedWords.add(entry.id);
entry.cellKeys.forEach((cellKey) => revealedCells.add(cellKey));
}
}
let score = 0;
let accurateWords = 0;
const placedResults = layout.entries.map((entry) => {
const playerAnswer = answerKey(finalAnswers[entry.id] ?? "");
const correct = playerAnswer === entry.answer || revealedWords.has(entry.id);
const revealedLetterCount = entry.cellKeys.filter((cellKey) => revealedCells.has(cellKey)).length;
if (correct && !revealedWords.has(entry.id)) {
accurateWords += 1;
score += Math.max(20, 100 - revealedLetterCount * 10);
}
return {
entryId: entry.id,
clue: entry.clue,
answer: entry.displayAnswer,
playerAnswer: finalAnswers[entry.id] ?? "",
explanation: entry.explanation,
correct,
omitted: false,
revealedLetters: revealedLetterCount,
revealedWord: revealedWords.has(entry.id),
alternateClueUsed: alternateClues.has(entry.id),
};
});
score = Math.max(0, score - incorrectChecks * 5);
return {
outcome: gaveUp ? "GAVE_UP" : "COMPLETED",
score,
maxScore: layout.entries.length * 100,
accuracy: layout.entries.length === 0 ? 0 : accurateWords / layout.entries.length,
durationSeconds,
mistakes: incorrectChecks,
hintsUsed,
size: settings.size,
placedCount: layout.entries.length,
omittedCount: layout.omittedEntries.length,
entries: [
...placedResults,
...layout.omittedEntries.map((entry) => ({
entryId: entry.id,
clue: entry.clue,
answer: entry.displayAnswer,
playerAnswer: "",
explanation: entry.explanation,
correct: false,
omitted: true,
revealedLetters: 0,
revealedWord: false,
alternateClueUsed: false,
})),
],
};
}

Some files were not shown because too many files have changed in this diff Show more