diff --git a/.gitignore b/.gitignore index 5ef6a52..03f032b 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md index 8bd0e39..e33c9fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,350 @@ - -# 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. - +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///`. +- 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. \ No newline at end of file diff --git a/dev.db b/dev.db deleted file mode 100644 index 2d75c15..0000000 Binary files a/dev.db and /dev/null differ diff --git a/package-lock.json b/package-lock.json index e127911..f81787d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6ee3ab5..584aaee 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/prisma/migrations/20260712113000_add_study_activity/migration.sql b/prisma/migrations/20260712113000_add_study_activity/migration.sql new file mode 100644 index 0000000..817ba79 --- /dev/null +++ b/prisma/migrations/20260712113000_add_study_activity/migration.sql @@ -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"); diff --git a/prisma/migrations/20260713200500_add_spaced_repetition/migration.sql b/prisma/migrations/20260713200500_add_spaced_repetition/migration.sql new file mode 100644 index 0000000..abdf670 --- /dev/null +++ b/prisma/migrations/20260713200500_add_spaced_repetition/migration.sql @@ -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"); diff --git a/prisma/migrations/20260714010000_add_arcade/migration.sql b/prisma/migrations/20260714010000_add_arcade/migration.sql new file mode 100644 index 0000000..8c2336d --- /dev/null +++ b/prisma/migrations/20260714010000_add_arcade/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dc8a941..1e031d9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,9 +14,11 @@ model Class { sortOrder Int @default(0) createdAt DateTime @default(now()) - decks Deck[] - quizSets QuizSet[] - materialGroups MaterialGroup[] + decks Deck[] + quizSets QuizSet[] + materialGroups MaterialGroup[] + spacedRepetitionSets SpacedRepetitionSet[] + arcadePacks ArcadePack[] } model Deck { @@ -27,12 +29,13 @@ model Deck { sortOrder Int @default(0) createdAt DateTime @default(now()) - class Class @relation(fields: [classId], references: [id], onDelete: Cascade) - groupId String? - group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) - cards Flashcard[] - shareLink ShareLink? - progress StudyProgress[] + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + groupId String? + group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) + cards Flashcard[] + shareLink ShareLink? + progress StudyProgress[] + spacedRepetitionMemberships SpacedRepetitionSetDeck[] } model Flashcard { @@ -42,7 +45,8 @@ model Flashcard { back String sortOrder Int @default(0) - deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade) + deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade) + spacedRepetitionStates SpacedRepetitionCardState[] } model QuizSet { @@ -53,9 +57,9 @@ model QuizSet { sortOrder Int @default(0) createdAt DateTime @default(now()) - class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) groupId String? - group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) + group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) questions Question[] attempts QuizAttempt[] shareLink ShareLink? @@ -141,16 +145,121 @@ 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 name String - type String // "DECK" | "QUIZ" + type String // "DECK" | "QUIZ" sortOrder Int @default(0) createdAt DateTime @default(now()) - class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) decks Deck[] 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]) +} diff --git a/src/app/(protected)/[classSlug]/arcade/connections/[packId]/play/page.tsx b/src/app/(protected)/[classSlug]/arcade/connections/[packId]/play/page.tsx new file mode 100644 index 0000000..423ee89 --- /dev/null +++ b/src/app/(protected)/[classSlug]/arcade/connections/[packId]/play/page.tsx @@ -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 ; +} diff --git a/src/app/(protected)/[classSlug]/arcade/connections/page.tsx b/src/app/(protected)/[classSlug]/arcade/connections/page.tsx new file mode 100644 index 0000000..e27c052 --- /dev/null +++ b/src/app/(protected)/[classSlug]/arcade/connections/page.tsx @@ -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 ; +} diff --git a/src/app/(protected)/[classSlug]/arcade/crossword/[packId]/play/page.tsx b/src/app/(protected)/[classSlug]/arcade/crossword/[packId]/play/page.tsx new file mode 100644 index 0000000..e197665 --- /dev/null +++ b/src/app/(protected)/[classSlug]/arcade/crossword/[packId]/play/page.tsx @@ -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(["mini", "standard", "large", "extra-large"]); + +export default async function CrosswordPlayPage(props: { params: Promise<{ classSlug: string; packId: string }>; searchParams: Promise> }) { + 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 ; +} diff --git a/src/app/(protected)/[classSlug]/arcade/crossword/page.tsx b/src/app/(protected)/[classSlug]/arcade/crossword/page.tsx new file mode 100644 index 0000000..f96fcba --- /dev/null +++ b/src/app/(protected)/[classSlug]/arcade/crossword/page.tsx @@ -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 ; +} diff --git a/src/app/(protected)/[classSlug]/arcade/layout.tsx b/src/app/(protected)/[classSlug]/arcade/layout.tsx new file mode 100644 index 0000000..5718c28 --- /dev/null +++ b/src/app/(protected)/[classSlug]/arcade/layout.tsx @@ -0,0 +1,3 @@ +export default function ArcadeLayout({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/src/app/(protected)/[classSlug]/arcade/page.tsx b/src/app/(protected)/[classSlug]/arcade/page.tsx new file mode 100644 index 0000000..27aa1b4 --- /dev/null +++ b/src/app/(protected)/[classSlug]/arcade/page.tsx @@ -0,0 +1,46 @@ +import Link from "next/link"; +import { ARCADE_GAMES } from "@/config/arcadeGames"; + +function ConnectionsVisual() { + return
{Array.from({ length: 16 }, (_, index) => )}
4 hidden groups
; +} + +function CrosswordVisual() { + const cells = ["C", "", "", "", "R", "", "O", "", "S", "T", "U", "D", "Y", "", "S", "", "", "", "W", "", "", "", "O", "", "", "", "R", "", "", "", "D", ""]; + return
{cells.map((letter, index) => {letter})}
; +} + +function FallingBlocksVisual() { + return
{Array.from({ length: 9 }, (_, index) => )}
; +} + +function AsteroidVisual() { + return
; +} + +function GameVisual({ gameKey }: { gameKey: (typeof ARCADE_GAMES)[number]["key"] }) { + if (gameKey === "connections") return ; + if (gameKey === "crossword") return ; + if (gameKey === "falling-blocks") return ; + return ; +} + +export default async function ArcadePage(props: PageProps<"/[classSlug]/arcade">) { + const { classSlug } = await props.params; + return ( +
+
+

Insert curiosity

+

Choose your cabinet

+

Step away from the desk and turn your study material into a quick challenge.

+
+ +
+ {ARCADE_GAMES.map((game) => { + const card =

{game.estimatedMinutes}

{game.name}

{game.description}

{game.available ? `Play ${game.name} →` : "Coming soon"}
; + return game.available ? {card} :
{card}
; + })} +
+
+ ); +} diff --git a/src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx b/src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx index 39cae8a..726de4e 100644 --- a/src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx +++ b/src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx @@ -90,9 +90,9 @@ export default function DeckStudyPage() { } return ( -
+
{/* Header */} -
+
-

{deck.name}

+

Flashcard session

+

{deck.name}

{deck.description && (

{deck.description}

)} @@ -126,7 +127,7 @@ export default function DeckStudyPage() {
-
+
- - ) : ( - - Study - - )} - - + + ) : ( + + Study + + )} +
+
+ + + +
@@ -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 ( -
+
{/* Header */} -
-

Flashcard Decks

-
- -
{isCreatingGroup && ( -
+
-
-

Edit Deck

+
+

Edit deck

{groupedDecks.map(group => ( -
+
{editingGroupId === group.id ? (
@@ -456,7 +467,7 @@ export default function FlashcardsPage() { -

{group.name}

+

{group.name}

)} @@ -484,7 +495,7 @@ export default function FlashcardsPage() { ))} {/* Uncategorized */} -
+
- +
+
+ {quiz.progress?.length ? ( + <> + + Continue + + + + ) : ( + + Start Quiz + + )} +
+
+ + + +
@@ -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 ( -
+
{/* Header */} -
-

Quizzes

-
- -
{isCreatingGroup && ( -
+
-
-

Edit Quiz

+
+

Edit quiz

{groupedQuizzes.map(group => ( -
+
{editingGroupId === group.id ? (
@@ -431,7 +471,7 @@ export default function QuizzesPage() { -

{group.name}

+

{group.name}

)} @@ -459,7 +499,7 @@ export default function QuizzesPage() { ))} {/* Uncategorized */} -
+
+ + + + )} + +
-

Your Classes

-

- Select a class to study flashcards or take quizzes -

+

Study spaces

+

Your classes

- +

Flashcards and quizzes, organized by class.

- {/* Create class inline form */} - {showCreate && ( -
-
- 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" - /> - - -
-
- )} - - {/* Loading state */} {loading && ( -
- {[1, 2, 3].map((i) => ( -
-
-
-
- ))} +
+ {[1, 2, 3].map((i) =>
)}
)} - {/* Empty state */} {!loading && classes.length === 0 && ( -
-
- - - -
-

- No classes yet -

-

- Create your first class to start studying -

- +
+
+
+

Your desk is ready

+

Create your first class, then add flashcards or practice quizzes.

+
)} - {/* Class grid */} {!loading && classes.length > 0 && ( -
- {classes.map((cls) => ( -
- +
+ {classes.map((cls, index) => ( +
+
+ +
+
{cls.name.slice(0, 2).toUpperCase()}
+ +
{editingId === cls.id ? ( - { - 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" - /> - ) : ( -

- {cls.name} -

- )} -
- - - - - {cls._count.decks} {cls._count.decks === 1 ? "deck" : "decks"} - - - - - - {cls._count.quizSets} {cls._count.quizSets === 1 ? "quiz" : "quizzes"} - + { 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" /> + ) :

{cls.name}

} +
+ {cls._count.decks} {cls._count.decks === 1 ? "deck" : "decks"} + {cls._count.quizSets} {cls._count.quizSets === 1 ? "quiz" : "quizzes"} + {cls.spacedRepetitionReadyCards > 0 && ( + + {cls.spacedRepetitionReadyCards} {cls.spacedRepetitionReadyCards === 1 ? "card" : "cards"} ready + + )}
- - {/* Actions */} -
- {/* Delete button */} - - {/* Edit button */} - +
+ +
-
+
))}
)} diff --git a/src/app/api/activity/route.ts b/src/app/api/activity/route.ts new file mode 100644 index 0000000..570793f --- /dev/null +++ b/src/app/api/activity/route.ts @@ -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 }); +} diff --git a/src/app/api/arcade/import/preview/route.ts b/src/app/api/arcade/import/preview/route.ts new file mode 100644 index 0000000..89370dd --- /dev/null +++ b/src/app/api/arcade/import/preview/route.ts @@ -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; + } +} diff --git a/src/app/api/arcade/packs/[id]/attempts/route.ts b/src/app/api/arcade/packs/[id]/attempts/route.ts new file mode 100644 index 0000000..f36cf38 --- /dev/null +++ b/src/app/api/arcade/packs/[id]/attempts/route.ts @@ -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[1]) + : await arcadeService.createArcadeAttempt(id, parsed.data as Parameters[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 } + ); + } +} diff --git a/src/app/api/arcade/packs/[id]/layout/route.ts b/src/app/api/arcade/packs/[id]/layout/route.ts new file mode 100644 index 0000000..db4cd23 --- /dev/null +++ b/src/app/api/arcade/packs/[id]/layout/route.ts @@ -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(["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}`)); +} diff --git a/src/app/api/arcade/packs/[id]/route.ts b/src/app/api/arcade/packs/[id]/route.ts new file mode 100644 index 0000000..4ad04f4 --- /dev/null +++ b/src/app/api/arcade/packs/[id]/route.ts @@ -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 }); +} diff --git a/src/app/api/arcade/packs/route.ts b/src/app/api/arcade/packs/route.ts new file mode 100644 index 0000000..e86938e --- /dev/null +++ b/src/app/api/arcade/packs/route.ts @@ -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; + } +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 018a809..d609732 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -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 }); } diff --git a/src/app/api/auth/password-reset/complete/route.ts b/src/app/api/auth/password-reset/complete/route.ts new file mode 100644 index 0000000..e614085 --- /dev/null +++ b/src/app/api/auth/password-reset/complete/route.ts @@ -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 }); +} diff --git a/src/app/api/auth/password-reset/request/route.ts b/src/app/api/auth/password-reset/request/route.ts new file mode 100644 index 0000000..5ac61d3 --- /dev/null +++ b/src/app/api/auth/password-reset/request/route.ts @@ -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 }); +} diff --git a/src/app/api/auth/password-reset/verify/route.ts b/src/app/api/auth/password-reset/verify/route.ts new file mode 100644 index 0000000..fc05690 --- /dev/null +++ b/src/app/api/auth/password-reset/verify/route.ts @@ -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 }); +} diff --git a/src/app/api/decks/reorder/route.ts b/src/app/api/decks/reorder/route.ts index 97313be..c7bcc43 100644 --- a/src/app/api/decks/reorder/route.ts +++ b/src/app/api/decks/reorder/route.ts @@ -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: { diff --git a/src/app/api/material-groups/[id]/route.ts b/src/app/api/material-groups/[id]/route.ts index f2e0316..bbd03cf 100644 --- a/src/app/api/material-groups/[id]/route.ts +++ b/src/app/api/material-groups/[id]/route.ts @@ -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; diff --git a/src/app/api/material-groups/route.ts b/src/app/api/material-groups/route.ts index 974b8ee..6184603 100644 --- a/src/app/api/material-groups/route.ts +++ b/src/app/api/material-groups/route.ts @@ -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; } diff --git a/src/app/api/quizzes/reorder/route.ts b/src/app/api/quizzes/reorder/route.ts index cc6d499..7ad7087 100644 --- a/src/app/api/quizzes/reorder/route.ts +++ b/src/app/api/quizzes/reorder/route.ts @@ -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: { diff --git a/src/app/api/settings/llm-instructions/route.ts b/src/app/api/settings/llm-instructions/route.ts index 1e5f7cf..87d57cf 100644 --- a/src/app/api/settings/llm-instructions/route.ts +++ b/src/app/api/settings/llm-instructions/route.ts @@ -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") { diff --git a/src/app/api/spaced-repetition-sets/[id]/decks/[deckId]/route.ts b/src/app/api/spaced-repetition-sets/[id]/decks/[deckId]/route.ts new file mode 100644 index 0000000..8abeee4 --- /dev/null +++ b/src/app/api/spaced-repetition-sets/[id]/decks/[deckId]/route.ts @@ -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); + } +} diff --git a/src/app/api/spaced-repetition-sets/[id]/decks/route.ts b/src/app/api/spaced-repetition-sets/[id]/decks/route.ts new file mode 100644 index 0000000..805af67 --- /dev/null +++ b/src/app/api/spaced-repetition-sets/[id]/decks/route.ts @@ -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); + } +} diff --git a/src/app/api/spaced-repetition-sets/[id]/reviews/route.ts b/src/app/api/spaced-repetition-sets/[id]/reviews/route.ts new file mode 100644 index 0000000..4e11cf8 --- /dev/null +++ b/src/app/api/spaced-repetition-sets/[id]/reviews/route.ts @@ -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); + } +} diff --git a/src/app/api/spaced-repetition-sets/[id]/route.ts b/src/app/api/spaced-repetition-sets/[id]/route.ts new file mode 100644 index 0000000..721f616 --- /dev/null +++ b/src/app/api/spaced-repetition-sets/[id]/route.ts @@ -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); + } +} diff --git a/src/app/api/spaced-repetition-sets/[id]/study/route.ts b/src/app/api/spaced-repetition-sets/[id]/study/route.ts new file mode 100644 index 0000000..5addeed --- /dev/null +++ b/src/app/api/spaced-repetition-sets/[id]/study/route.ts @@ -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); + } +} diff --git a/src/app/api/spaced-repetition-sets/route.ts b/src/app/api/spaced-repetition-sets/route.ts new file mode 100644 index 0000000..bcc2ec3 --- /dev/null +++ b/src/app/api/spaced-repetition-sets/route.ts @@ -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); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index cfd5955..75eeb37 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d0e2e7a..6fbaecf 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - +