diff --git a/.gitignore b/.gitignore index 03f032b..5ef6a52 100644 --- a/.gitignore +++ b/.gitignore @@ -13,12 +13,6 @@ # 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 e33c9fe..8bd0e39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,350 +1,5 @@ -# Study Desk - AI Contributor Guide + +# This is NOT the Next.js you know -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 +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. + diff --git a/dev.db b/dev.db new file mode 100644 index 0000000..a0c488e Binary files /dev/null and b/dev.db differ diff --git a/package-lock.json b/package-lock.json index f81787d..e127911 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,6 @@ "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,8 +33,7 @@ "eslint-config-next": "16.2.9", "prisma": "^7.8.0", "tailwindcss": "^4", - "typescript": "^5", - "vitest": "^4.1.10" + "typescript": "^5" } }, "node_modules/@alloc/quick-lru": { @@ -1351,16 +1349,6 @@ "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", @@ -1749,293 +1737,6 @@ } } }, - "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", @@ -2341,17 +2042,6 @@ "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", @@ -2361,13 +2051,6 @@ "@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", @@ -2427,7 +2110,6 @@ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3108,119 +2790,6 @@ "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", @@ -3471,16 +3040,6 @@ "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", @@ -3836,16 +3395,6 @@ "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", @@ -4512,13 +4061,6 @@ "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", @@ -5013,16 +4555,6 @@ "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", @@ -5042,16 +4574,6 @@ "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", @@ -5275,21 +4797,6 @@ "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", @@ -8168,20 +7675,6 @@ "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", @@ -8914,40 +8407,6 @@ "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", @@ -9280,13 +8739,6 @@ "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", @@ -9381,13 +8833,6 @@ "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", @@ -9685,23 +9130,6 @@ "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", @@ -9751,16 +9179,6 @@ "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", @@ -9807,15 +9225,6 @@ "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", @@ -10237,236 +9646,6 @@ "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", @@ -10571,23 +9750,6 @@ "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 584aaee..6ee3ab5 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint", - "test": "vitest run" + "lint": "eslint" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -24,7 +23,6 @@ "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": { @@ -36,7 +34,6 @@ "eslint-config-next": "16.2.9", "prisma": "^7.8.0", "tailwindcss": "^4", - "typescript": "^5", - "vitest": "^4.1.10" + "typescript": "^5" } } diff --git a/prisma/migrations/20260712113000_add_study_activity/migration.sql b/prisma/migrations/20260712113000_add_study_activity/migration.sql deleted file mode 100644 index 817ba79..0000000 --- a/prisma/migrations/20260712113000_add_study_activity/migration.sql +++ /dev/null @@ -1,9 +0,0 @@ --- CreateTable -CREATE TABLE "StudyActivity" ( - "id" TEXT NOT NULL PRIMARY KEY, - "type" TEXT NOT NULL, - "occurredAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- CreateIndex -CREATE INDEX "StudyActivity_occurredAt_idx" ON "StudyActivity"("occurredAt"); diff --git a/prisma/migrations/20260713200500_add_spaced_repetition/migration.sql b/prisma/migrations/20260713200500_add_spaced_repetition/migration.sql deleted file mode 100644 index abdf670..0000000 --- a/prisma/migrations/20260713200500_add_spaced_repetition/migration.sql +++ /dev/null @@ -1,64 +0,0 @@ --- CreateTable -CREATE TABLE "SpacedRepetitionSet" ( - "id" TEXT NOT NULL PRIMARY KEY, - "classId" TEXT NOT NULL, - "name" TEXT NOT NULL, - "description" TEXT, - "newCardsPerDay" INTEGER NOT NULL DEFAULT 30, - "sortOrder" INTEGER NOT NULL DEFAULT 0, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" DATETIME NOT NULL, - CONSTRAINT "SpacedRepetitionSet_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE -); - --- CreateTable -CREATE TABLE "SpacedRepetitionSetDeck" ( - "setId" TEXT NOT NULL, - "deckId" TEXT NOT NULL, - "sortOrder" INTEGER NOT NULL DEFAULT 0, - "addedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - - PRIMARY KEY ("setId", "deckId"), - CONSTRAINT "SpacedRepetitionSetDeck_setId_fkey" FOREIGN KEY ("setId") REFERENCES "SpacedRepetitionSet" ("id") ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT "SpacedRepetitionSetDeck_deckId_fkey" FOREIGN KEY ("deckId") REFERENCES "Deck" ("id") ON DELETE CASCADE ON UPDATE CASCADE -); - --- CreateTable -CREATE TABLE "SpacedRepetitionCardState" ( - "id" TEXT NOT NULL PRIMARY KEY, - "setId" TEXT NOT NULL, - "flashcardId" TEXT NOT NULL, - "due" DATETIME NOT NULL, - "stability" REAL NOT NULL, - "difficulty" REAL NOT NULL, - "elapsedDays" INTEGER NOT NULL, - "scheduledDays" INTEGER NOT NULL, - "learningSteps" INTEGER NOT NULL DEFAULT 0, - "reps" INTEGER NOT NULL, - "lapses" INTEGER NOT NULL, - "state" INTEGER NOT NULL, - "firstReviewedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "lastReview" DATETIME, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" DATETIME NOT NULL, - CONSTRAINT "SpacedRepetitionCardState_setId_fkey" FOREIGN KEY ("setId") REFERENCES "SpacedRepetitionSet" ("id") ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT "SpacedRepetitionCardState_flashcardId_fkey" FOREIGN KEY ("flashcardId") REFERENCES "Flashcard" ("id") ON DELETE CASCADE ON UPDATE CASCADE -); - --- CreateIndex -CREATE INDEX "SpacedRepetitionSet_classId_sortOrder_idx" ON "SpacedRepetitionSet"("classId", "sortOrder"); - --- CreateIndex -CREATE INDEX "SpacedRepetitionSetDeck_deckId_idx" ON "SpacedRepetitionSetDeck"("deckId"); - --- CreateIndex -CREATE INDEX "SpacedRepetitionCardState_setId_due_idx" ON "SpacedRepetitionCardState"("setId", "due"); - --- CreateIndex -CREATE INDEX "SpacedRepetitionCardState_setId_firstReviewedAt_idx" ON "SpacedRepetitionCardState"("setId", "firstReviewedAt"); - --- CreateIndex -CREATE INDEX "SpacedRepetitionCardState_flashcardId_idx" ON "SpacedRepetitionCardState"("flashcardId"); - --- CreateIndex -CREATE UNIQUE INDEX "SpacedRepetitionCardState_setId_flashcardId_key" ON "SpacedRepetitionCardState"("setId", "flashcardId"); diff --git a/prisma/migrations/20260714010000_add_arcade/migration.sql b/prisma/migrations/20260714010000_add_arcade/migration.sql deleted file mode 100644 index 8c2336d..0000000 --- a/prisma/migrations/20260714010000_add_arcade/migration.sql +++ /dev/null @@ -1,40 +0,0 @@ --- CreateTable -CREATE TABLE "ArcadePack" ( - "id" TEXT NOT NULL PRIMARY KEY, - "classId" TEXT NOT NULL, - "gameType" TEXT NOT NULL, - "name" TEXT NOT NULL, - "description" TEXT, - "schemaVersion" INTEGER NOT NULL, - "sourceJson" TEXT NOT NULL, - "normalizedJson" TEXT NOT NULL, - "validationReportJson" TEXT NOT NULL, - "sortOrder" INTEGER NOT NULL DEFAULT 0, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" DATETIME NOT NULL, - CONSTRAINT "ArcadePack_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE -); - --- CreateTable -CREATE TABLE "ArcadeAttempt" ( - "id" TEXT NOT NULL PRIMARY KEY, - "arcadePackId" TEXT NOT NULL, - "mode" TEXT NOT NULL, - "score" INTEGER NOT NULL, - "maxScore" INTEGER NOT NULL, - "accuracy" REAL NOT NULL, - "durationSeconds" INTEGER NOT NULL, - "mistakes" INTEGER NOT NULL, - "hintsUsed" INTEGER NOT NULL, - "settingsJson" TEXT NOT NULL, - "resultsJson" TEXT NOT NULL, - "seed" TEXT NOT NULL, - "completedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "ArcadeAttempt_arcadePackId_fkey" FOREIGN KEY ("arcadePackId") REFERENCES "ArcadePack" ("id") ON DELETE CASCADE ON UPDATE CASCADE -); - --- CreateIndex -CREATE INDEX "ArcadePack_classId_gameType_sortOrder_idx" ON "ArcadePack"("classId", "gameType", "sortOrder"); - --- CreateIndex -CREATE INDEX "ArcadeAttempt_arcadePackId_completedAt_idx" ON "ArcadeAttempt"("arcadePackId", "completedAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1e031d9..dc8a941 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,11 +14,9 @@ model Class { sortOrder Int @default(0) createdAt DateTime @default(now()) - decks Deck[] - quizSets QuizSet[] - materialGroups MaterialGroup[] - spacedRepetitionSets SpacedRepetitionSet[] - arcadePacks ArcadePack[] + decks Deck[] + quizSets QuizSet[] + materialGroups MaterialGroup[] } model Deck { @@ -29,13 +27,12 @@ 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[] - spacedRepetitionMemberships SpacedRepetitionSetDeck[] + 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[] } model Flashcard { @@ -45,8 +42,7 @@ model Flashcard { back String sortOrder Int @default(0) - deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade) - spacedRepetitionStates SpacedRepetitionCardState[] + deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade) } model QuizSet { @@ -57,9 +53,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? @@ -145,121 +141,16 @@ 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 deleted file mode 100644 index 423ee89..0000000 --- a/src/app/(protected)/[classSlug]/arcade/connections/[packId]/play/page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { notFound } from "next/navigation"; -import { randomUUID } from "node:crypto"; -import { ARCADE_RENDERERS } from "@/components/arcade/rendererRegistry"; -import { getArcadePack } from "@/services/arcadeService"; - -export default async function ConnectionsPlayPage( - props: PageProps<"/[classSlug]/arcade/connections/[packId]/play"> -) { - const [{ classSlug, packId }, query] = await Promise.all([props.params, props.searchParams]); - const pack = await getArcadePack(packId); - if (!pack || pack.class.slug !== classSlug || pack.gameType !== "connections" || pack.normalized.type !== "connections") notFound(); - const normalized = pack.normalized; - const mistakesValue = Number(query.mistakes); - const allowedMistakes = Number.isInteger(mistakesValue) && mistakesValue >= 1 && mistakesValue <= 8 - ? mistakesValue - : normalized.settings.allowedMistakes; - const Renderer = ARCADE_RENDERERS.connections; - return ; -} diff --git a/src/app/(protected)/[classSlug]/arcade/connections/page.tsx b/src/app/(protected)/[classSlug]/arcade/connections/page.tsx deleted file mode 100644 index e27c052..0000000 --- a/src/app/(protected)/[classSlug]/arcade/connections/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { notFound } from "next/navigation"; -import { ConnectionsHub } from "@/components/arcade/ConnectionsHub"; -import { getClassBySlug } from "@/services/classService"; -import { listArcadePacks } from "@/services/arcadeService"; - -export default async function ConnectionsHubPage(props: PageProps<"/[classSlug]/arcade/connections">) { - const { classSlug } = await props.params; - const classItem = await getClassBySlug(classSlug); - if (!classItem) notFound(); - const packs = await listArcadePacks(classItem.id, "connections"); - return ; -} diff --git a/src/app/(protected)/[classSlug]/arcade/crossword/[packId]/play/page.tsx b/src/app/(protected)/[classSlug]/arcade/crossword/[packId]/play/page.tsx deleted file mode 100644 index e197665..0000000 --- a/src/app/(protected)/[classSlug]/arcade/crossword/[packId]/play/page.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { notFound } from "next/navigation"; -import { ARCADE_RENDERERS } from "@/components/arcade/rendererRegistry"; -import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine"; -import type { CrosswordSize, NormalizedCrosswordPack } from "@/types/arcade"; -import { getArcadePack } from "@/services/arcadeService"; - -const SIZES = new Set(["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 deleted file mode 100644 index f96fcba..0000000 --- a/src/app/(protected)/[classSlug]/arcade/crossword/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { notFound } from "next/navigation"; -import { CrosswordHub } from "@/components/arcade/CrosswordHub"; -import { getClassBySlug } from "@/services/classService"; -import { listArcadePacks } from "@/services/arcadeService"; - -export default async function CrosswordHubPage(props: { params: Promise<{ classSlug: string }> }) { - const { classSlug } = await props.params; - const classItem = await getClassBySlug(classSlug); - if (!classItem) notFound(); - const packs = await listArcadePacks(classItem.id, "crossword"); - return ; -} diff --git a/src/app/(protected)/[classSlug]/arcade/layout.tsx b/src/app/(protected)/[classSlug]/arcade/layout.tsx deleted file mode 100644 index 5718c28..0000000 --- a/src/app/(protected)/[classSlug]/arcade/layout.tsx +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 27aa1b4..0000000 --- a/src/app/(protected)/[classSlug]/arcade/page.tsx +++ /dev/null @@ -1,46 +0,0 @@ -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/page.tsx b/src/app/(protected)/[classSlug]/flashcards/page.tsx index 8a455f1..6957319 100644 --- a/src/app/(protected)/[classSlug]/flashcards/page.tsx +++ b/src/app/(protected)/[classSlug]/flashcards/page.tsx @@ -27,7 +27,6 @@ import { useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import type { ReorderItem } from "@/types/study"; interface DeckItem { id: string; @@ -66,14 +65,7 @@ function getProgressLabel(deck: DeckItem) { return `${current}/${total}, ${modeLabel} · ${correctCount} ✓`; } -interface SortableDeckCardProps { - deck: DeckItem; - onEdit: (deck: DeckItem) => void; - onDelete: (deck: DeckItem) => void; - classSlug: string; -} - -function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCardProps) { +function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: deck.id }); const style = { transform: CSS.Transform.toString(transform), @@ -107,10 +99,9 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCar )} -
-
- {deck.progress?.length ? ( - <> +
+ {deck.progress?.length ? ( + <> Continue @@ -128,26 +119,22 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCar > Restart - - ) : ( - - Study - - )} -
-
- - - -
+ + ) : ( + + Study + + )} + +
@@ -322,6 +309,8 @@ export default function FlashcardsPage() { if (!activeDeck) return; let targetGroupId: string | null = null; + let targetIndex = 0; + const overContainerId = over.data.current?.sortable?.containerId; if (overContainerId) { targetGroupId = overContainerId === "uncategorized" ? null : overContainerId; @@ -350,7 +339,7 @@ export default function FlashcardsPage() { } const affectedGroups = new Set([activeDeck.groupId, targetGroupId]); - const updates: ReorderItem[] = []; + const updates: any[] = []; affectedGroups.forEach(gId => { const gItems = newItems.filter(d => d.groupId === gId); diff --git a/src/app/(protected)/[classSlug]/layout.tsx b/src/app/(protected)/[classSlug]/layout.tsx index 85d64bd..09c5c86 100644 --- a/src/app/(protected)/[classSlug]/layout.tsx +++ b/src/app/(protected)/[classSlug]/layout.tsx @@ -12,8 +12,8 @@ export default async function ClassLayout(props: LayoutProps<"/[classSlug]">) { return (
- {/* Dynamic class header */} - + {/* Dynamic Header & Tabs */} + {/* Page content */} {props.children} diff --git a/src/app/(protected)/[classSlug]/quizzes/page.tsx b/src/app/(protected)/[classSlug]/quizzes/page.tsx index 97be050..cd45b56 100644 --- a/src/app/(protected)/[classSlug]/quizzes/page.tsx +++ b/src/app/(protected)/[classSlug]/quizzes/page.tsx @@ -27,7 +27,6 @@ import { useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import type { ReorderItem } from "@/types/study"; interface QuizItem { id: string; @@ -36,12 +35,6 @@ interface QuizItem { groupId: string | null; sortOrder: number; _count: { questions: number; attempts: number }; - progress: Array<{ - mode: string; - currentIndex: number; - orderJson: string; - answersJson: string | null; - }>; } interface MaterialGroup { @@ -52,14 +45,7 @@ interface MaterialGroup { const cache: Record = {}; -interface SortableQuizCardProps { - quiz: QuizItem; - onEdit: (quiz: QuizItem) => void; - onDelete: (quiz: QuizItem) => void; - classSlug: string; -} - -function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCardProps) { +function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: quiz.id }); const style = { transform: CSS.Transform.toString(transform), @@ -93,48 +79,20 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCar )}
-
-
- {quiz.progress?.length ? ( - <> - - Continue - - - - ) : ( - - Start Quiz - - )} -
-
- - - -
+
+ + Start Quiz + + +
@@ -320,6 +278,8 @@ export default function QuizzesPage() { if (!activeQuiz) return; let targetGroupId: string | null = null; + let targetIndex = 0; + // Check if over a group container directly const overContainerId = over.data.current?.sortable?.containerId; if (overContainerId) { @@ -351,7 +311,7 @@ export default function QuizzesPage() { // Re-calculate sortOrder for the affected groups to persist const affectedGroups = new Set([activeQuiz.groupId, targetGroupId]); - const updates: ReorderItem[] = []; + const updates: any[] = []; affectedGroups.forEach(gId => { const gItems = newItems.filter(q => q.groupId === gId); diff --git a/src/app/(protected)/[classSlug]/spaced-repetition/[setId]/page.tsx b/src/app/(protected)/[classSlug]/spaced-repetition/[setId]/page.tsx deleted file mode 100644 index 4bd9feb..0000000 --- a/src/app/(protected)/[classSlug]/spaced-repetition/[setId]/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SpacedRepetitionViewer } from "@/components/spaced-repetition/SpacedRepetitionViewer"; - -export default function SpacedRepetitionStudyPage() { - return ; -} diff --git a/src/app/(protected)/[classSlug]/spaced-repetition/page.tsx b/src/app/(protected)/[classSlug]/spaced-repetition/page.tsx deleted file mode 100644 index b07ee3b..0000000 --- a/src/app/(protected)/[classSlug]/spaced-repetition/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SpacedRepetitionSets } from "@/components/spaced-repetition/SpacedRepetitionSets"; - -export default function SpacedRepetitionPage() { - return ; -} diff --git a/src/app/(protected)/page.tsx b/src/app/(protected)/page.tsx index 5d37387..88ece8c 100644 --- a/src/app/(protected)/page.tsx +++ b/src/app/(protected)/page.tsx @@ -2,17 +2,12 @@ import { useEffect, useState } from "react"; import Link from "next/link"; -import { ActivityBanner } from "@/components/activity/ActivityBanner"; interface ClassItem { id: string; slug: string; name: string; _count: { decks: number; quizSets: number }; - spacedRepetitionDueCards: number; - spacedRepetitionLearningCards: number; - spacedRepetitionNewCards: number; - spacedRepetitionReadyCards: number; } const accents = ["#4f46e5", "#f97360", "#25845f", "#c27718", "#8b5cf6", "#0284c7"]; @@ -61,9 +56,33 @@ export default function HomePage() { } finally { setSavingEdit(false); } } + const deckTotal = classes.reduce((sum, item) => sum + item._count.decks, 0); + const quizTotal = classes.reduce((sum, item) => sum + item._count.quizSets, 0); + return (
- setShowCreate(true)} /> +
+
+
+
+
+

Personal library

+

What are we learning today?

+

Pick up where you left off, sharpen a weak topic, or build something new.

+
+ +
+
+ {[[classes.length, "Classes"], [deckTotal, "Decks"], [quizTotal, "Quizzes"]].map(([value, label]) => ( +
+
{value}
+
{label}
+
+ ))} +
+
{showCreate && (
@@ -118,14 +137,6 @@ export default function HomePage() {
{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 - - )}
diff --git a/src/app/api/activity/route.ts b/src/app/api/activity/route.ts deleted file mode 100644 index 570793f..0000000 --- a/src/app/api/activity/route.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { studyActivitySchema } from "@/lib/validation/activitySchemas"; -import * as activityService from "@/services/activityService"; - -export async function GET() { - return NextResponse.json(await activityService.getActivitySummary()); -} - -export async function POST(request: NextRequest) { - const parsed = studyActivitySchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid activity type" }, { status: 400 }); - } - - await activityService.recordActivity(parsed.data.type); - return NextResponse.json({ success: true }, { status: 201 }); -} diff --git a/src/app/api/arcade/import/preview/route.ts b/src/app/api/arcade/import/preview/route.ts deleted file mode 100644 index 89370dd..0000000 --- a/src/app/api/arcade/import/preview/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { ArcadeImportError } from "@/lib/arcade/arcadeImport"; -import { arcadePreviewRequestSchema } from "@/lib/validation/arcadeSchemas"; -import * as arcadeService from "@/services/arcadeService"; - -export async function POST(request: NextRequest) { - const parsed = arcadePreviewRequestSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: "A game type and raw JSON are required." }, { status: 400 }); - } - try { - return NextResponse.json(arcadeService.previewArcadeImport(parsed.data.gameType, parsed.data.rawJson)); - } catch (error) { - if (error instanceof ArcadeImportError) { - return NextResponse.json({ error: error.message, details: error.details }, { status: 400 }); - } - throw error; - } -} diff --git a/src/app/api/arcade/packs/[id]/attempts/route.ts b/src/app/api/arcade/packs/[id]/attempts/route.ts deleted file mode 100644 index f36cf38..0000000 --- a/src/app/api/arcade/packs/[id]/attempts/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { arcadeAttemptCreateSchema, crosswordAttemptCreateSchema } from "@/lib/validation/arcadeSchemas"; -import * as arcadeService from "@/services/arcadeService"; - -export async function GET( - _request: NextRequest, - context: RouteContext<"/api/arcade/packs/[id]/attempts"> -) { - const { id } = await context.params; - const pack = await arcadeService.getArcadePack(id); - if (!pack) return NextResponse.json({ error: "Arcade pack not found" }, { status: 404 }); - return NextResponse.json(await arcadeService.listArcadeAttempts(id)); -} - -export async function POST( - request: NextRequest, - context: RouteContext<"/api/arcade/packs/[id]/attempts"> -) { - const { id } = await context.params; - const pack = await arcadeService.getArcadePack(id); - if (!pack) return NextResponse.json({ error: "Arcade pack not found" }, { status: 404 }); - const body = await request.json().catch(() => null); - const parsed = pack.gameType === "crossword" - ? crosswordAttemptCreateSchema.safeParse(body) - : arcadeAttemptCreateSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid completed attempt", details: parsed.error.issues }, { status: 400 }); - } - try { - const attempt = pack.gameType === "crossword" - ? await arcadeService.createCrosswordAttempt(id, parsed.data as Parameters[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 deleted file mode 100644 index db4cd23..0000000 --- a/src/app/api/arcade/packs/[id]/layout/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine"; -import type { CrosswordSize } from "@/types/arcade"; -import { getArcadePack } from "@/services/arcadeService"; - -const CROSSWORD_SIZES = new Set(["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 deleted file mode 100644 index 4ad04f4..0000000 --- a/src/app/api/arcade/packs/[id]/route.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { arcadePackUpdateSchema } from "@/lib/validation/arcadeSchemas"; -import * as arcadeService from "@/services/arcadeService"; - -export async function GET( - _request: NextRequest, - context: RouteContext<"/api/arcade/packs/[id]"> -) { - const { id } = await context.params; - const pack = await arcadeService.getArcadePack(id); - return pack - ? NextResponse.json(pack) - : NextResponse.json({ error: "Arcade pack not found" }, { status: 404 }); -} - -export async function PATCH( - request: NextRequest, - context: RouteContext<"/api/arcade/packs/[id]"> -) { - const { id } = await context.params; - const parsed = arcadePackUpdateSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) return NextResponse.json({ error: "A valid name is required" }, { status: 400 }); - const pack = await arcadeService.updateArcadePack(id, parsed.data.name); - return pack - ? NextResponse.json(pack) - : NextResponse.json({ error: "Arcade pack not found" }, { status: 404 }); -} - -export async function DELETE( - _request: NextRequest, - context: RouteContext<"/api/arcade/packs/[id]"> -) { - const { id } = await context.params; - const deleted = await arcadeService.deleteArcadePack(id); - return deleted - ? NextResponse.json({ success: true }) - : NextResponse.json({ error: "Arcade pack not found" }, { status: 404 }); -} diff --git a/src/app/api/arcade/packs/route.ts b/src/app/api/arcade/packs/route.ts deleted file mode 100644 index e86938e..0000000 --- a/src/app/api/arcade/packs/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { ArcadeImportError } from "@/lib/arcade/arcadeImport"; -import { arcadePackCreateSchema } from "@/lib/validation/arcadeSchemas"; -import * as arcadeService from "@/services/arcadeService"; - -export async function GET(request: NextRequest) { - const params = new URL(request.url).searchParams; - const classId = params.get("classId"); - const gameType = params.get("gameType"); - if (!classId || (gameType !== "connections" && gameType !== "crossword")) { - return NextResponse.json({ error: "Valid classId and gameType values are required." }, { status: 400 }); - } - return NextResponse.json(await arcadeService.listArcadePacks(classId, gameType)); -} - -export async function POST(request: NextRequest) { - const parsed = arcadePackCreateSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid Arcade pack request", details: parsed.error.issues }, { status: 400 }); - } - try { - const packs = await arcadeService.createArcadePacks(parsed.data); - return NextResponse.json({ packs, count: packs.length }, { status: 201 }); - } catch (error) { - if (error instanceof ArcadeImportError) { - return NextResponse.json({ error: error.message, details: error.details }, { status: 400 }); - } - if (error instanceof Error) { - const status = error.message === "Class not found" ? 404 : 400; - return NextResponse.json({ error: error.message }, { status }); - } - throw error; - } -} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index d609732..018a809 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,29 +1,116 @@ import { NextRequest, NextResponse } from "next/server"; -import { loginSchema } from "@/lib/validation/authSchemas"; +import { prisma } from "@/lib/db"; +import { createSession } from "@/lib/auth"; import { checkRateLimit } from "@/lib/rateLimiter"; -import { login } from "@/services/authService"; + +// Progressive lockout thresholds from Section 4 +function getLockoutDuration(failedAttempts: number): number { + if (failedAttempts >= 20) return 24 * 60 * 60 * 1000; // 24 hours + if (failedAttempts >= 15) return 30 * 60 * 1000; // 30 minutes + if (failedAttempts >= 10) return 5 * 60 * 1000; // 5 minutes + if (failedAttempts >= 5) return 1 * 60 * 1000; // 1 minute + return 0; +} export async function POST(request: NextRequest) { - const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; - if (!checkRateLimit(`login:${ip}`).allowed) { + // Rate limit by IP + const forwarded = request.headers.get("x-forwarded-for"); + const ip = forwarded?.split(",")[0]?.trim() ?? "unknown"; + const rateCheck = checkRateLimit(ip); + + if (!rateCheck.allowed) { return NextResponse.json( { error: "Too many requests. Try again shortly." }, { status: 429 } ); } - const parsed = loginSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { + const body = await request.json().catch(() => null); + if (!body?.password || typeof body.password !== "string") { return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid request" }, + { error: "Password is required" }, { status: 400 } ); } - const result = await login(parsed.data.password); - if (!result.ok) { - return NextResponse.json({ error: result.error }, { status: result.status }); + // Check lockout state + let security = await prisma.authSecurity.findUnique({ where: { id: 1 } }); + if (!security) { + security = await prisma.authSecurity.create({ data: { id: 1 } }); } + if (security.lockedUntil && security.lockedUntil > new Date()) { + const remainingMs = + security.lockedUntil.getTime() - Date.now(); + const remainingMin = Math.ceil(remainingMs / 60000); + return NextResponse.json( + { + error: `Account locked. Try again in ${remainingMin} minute${remainingMin !== 1 ? "s" : ""}.`, + }, + { status: 423 } + ); + } + + // Verify password + const setting = await prisma.setting.findUnique({ + where: { key: "admin_password_hash" }, + }); + + let isValid = false; + + if (!setting) { + // Initial setup: hash and save the new password + const argon2 = await import("argon2"); + const newHash = await argon2.hash(body.password); + await prisma.setting.create({ + data: { + key: "admin_password_hash", + value: newHash, + }, + }); + isValid = true; + } else { + // Verify existing password + const passwordHash = setting.value; + try { + const argon2 = await import("argon2"); + isValid = await argon2.verify(passwordHash, body.password); + } catch { + isValid = body.password === passwordHash; + } + } + + if (!isValid) { + const newFailedAttempts = security.failedAttempts + 1; + const lockoutMs = getLockoutDuration(newFailedAttempts); + const lockedUntil = lockoutMs > 0 ? new Date(Date.now() + lockoutMs) : null; + + await prisma.authSecurity.update({ + where: { id: 1 }, + data: { + failedAttempts: newFailedAttempts, + lockedUntil, + lastAttemptAt: new Date(), + }, + }); + + return NextResponse.json( + { error: "Invalid password" }, + { status: 401 } + ); + } + + // Success — reset lockout, create session + await prisma.authSecurity.update({ + where: { id: 1 }, + data: { + failedAttempts: 0, + lockedUntil: null, + lastAttemptAt: new Date(), + }, + }); + + await createSession(); + return NextResponse.json({ success: true }); } diff --git a/src/app/api/auth/password-reset/complete/route.ts b/src/app/api/auth/password-reset/complete/route.ts deleted file mode 100644 index e614085..0000000 --- a/src/app/api/auth/password-reset/complete/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 5ac61d3..0000000 --- a/src/app/api/auth/password-reset/request/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index fc05690..0000000 --- a/src/app/api/auth/password-reset/verify/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { checkRateLimit } from "@/lib/rateLimiter"; -import { resetTokenSchema } from "@/lib/validation/authSchemas"; -import { verifyPasswordResetToken } from "@/services/authService"; - -export async function POST(request: NextRequest) { - 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 c7bcc43..97313be 100644 --- a/src/app/api/decks/reorder/route.ts +++ b/src/app/api/decks/reorder/route.ts @@ -1,20 +1,19 @@ 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 parsed = reorderRequestSchema.safeParse(await request.json()); - if (!parsed.success) { + const body = await request.json(); + const { items } = body; + + if (!Array.isArray(items)) { 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) => + items.map((item: any) => 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 bbd03cf..f2e0316 100644 --- a/src/app/api/material-groups/[id]/route.ts +++ b/src/app/api/material-groups/[id]/route.ts @@ -1,6 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/db"; -import type { Prisma } from "@/generated/prisma/client"; export async function PATCH( request: NextRequest, @@ -11,7 +10,7 @@ export async function PATCH( const body = await request.json(); const { name, sortOrder } = body; - const updateData: Prisma.MaterialGroupUpdateInput = {}; + const updateData: any = {}; 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 6184603..974b8ee 100644 --- a/src/app/api/material-groups/route.ts +++ b/src/app/api/material-groups/route.ts @@ -1,6 +1,5 @@ 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); @@ -15,7 +14,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: "Invalid type" }, { status: 400 }); } - const whereClause: Prisma.MaterialGroupWhereInput = { classId }; + const whereClause: any = { 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 7ad7087..cc6d499 100644 --- a/src/app/api/quizzes/reorder/route.ts +++ b/src/app/api/quizzes/reorder/route.ts @@ -1,20 +1,19 @@ 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 parsed = reorderRequestSchema.safeParse(await request.json()); - if (!parsed.success) { + const body = await request.json(); + const { items } = body; + + if (!Array.isArray(items)) { 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) => + items.map((item: any) => 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 87d57cf..1e5f7cf 100644 --- a/src/app/api/settings/llm-instructions/route.ts +++ b/src/app/api/settings/llm-instructions/route.ts @@ -1,21 +1,16 @@ import { NextRequest, NextResponse } from "next/server"; import * as settingsService from "@/services/settingsService"; -function getInstructionType(request: NextRequest): settingsService.LlmInstructionType | null { - const type = new URL(request.url).searchParams.get("type") ?? "flashcards"; - return type === "flashcards" || type === "quizzes" || type === "connections" || type === "crossword" ? type : null; -} - export async function GET(request: NextRequest) { - const type = getInstructionType(request); - if (!type) return NextResponse.json({ error: "Invalid instruction type" }, { status: 400 }); + const { searchParams } = new URL(request.url); + const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards"; const instructions = await settingsService.getLlmInstructions(type); return NextResponse.json({ value: instructions }); } export async function PATCH(request: NextRequest) { - const type = getInstructionType(request); - if (!type) return NextResponse.json({ error: "Invalid instruction type" }, { status: 400 }); + const { searchParams } = new URL(request.url); + const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards"; const body = await request.json().catch(() => null); if (!body || typeof body.value !== "string") { 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 deleted file mode 100644 index 8abeee4..0000000 --- a/src/app/api/spaced-repetition-sets/[id]/decks/[deckId]/route.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi"; -import * as spacedRepetitionService from "@/services/spacedRepetitionService"; - -export async function DELETE( - _request: NextRequest, - context: { params: Promise<{ id: string; deckId: string }> } -) { - const { id, deckId } = await context.params; - try { - await spacedRepetitionService.removeDeck(id, deckId); - return NextResponse.json({ success: true }); - } catch (error) { - return spacedRepetitionErrorResponse(error); - } -} diff --git a/src/app/api/spaced-repetition-sets/[id]/decks/route.ts b/src/app/api/spaced-repetition-sets/[id]/decks/route.ts deleted file mode 100644 index 805af67..0000000 --- a/src/app/api/spaced-repetition-sets/[id]/decks/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { - addSpacedRepetitionDeckSchema, - reorderSpacedRepetitionDecksSchema, -} from "@/lib/validation/spacedRepetitionSchemas"; -import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi"; -import * as spacedRepetitionService from "@/services/spacedRepetitionService"; - -export async function POST( - request: NextRequest, - context: { params: Promise<{ id: string }> } -) { - const { id } = await context.params; - const parsed = addSpacedRepetitionDeckSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) return NextResponse.json({ error: "Invalid deck" }, { status: 400 }); - try { - return NextResponse.json( - await spacedRepetitionService.addDeck(id, parsed.data.deckId), - { status: 201 } - ); - } catch (error) { - return spacedRepetitionErrorResponse(error); - } -} - -export async function PATCH( - request: NextRequest, - context: { params: Promise<{ id: string }> } -) { - const { id } = await context.params; - const parsed = reorderSpacedRepetitionDecksSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) return NextResponse.json({ error: "Invalid deck order" }, { status: 400 }); - try { - await spacedRepetitionService.reorderDecks(id, parsed.data.deckIds); - return NextResponse.json({ success: true }); - } catch (error) { - return spacedRepetitionErrorResponse(error); - } -} diff --git a/src/app/api/spaced-repetition-sets/[id]/reviews/route.ts b/src/app/api/spaced-repetition-sets/[id]/reviews/route.ts deleted file mode 100644 index 4e11cf8..0000000 --- a/src/app/api/spaced-repetition-sets/[id]/reviews/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { reviewSpacedRepetitionCardSchema } from "@/lib/validation/spacedRepetitionSchemas"; -import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi"; -import * as spacedRepetitionService from "@/services/spacedRepetitionService"; - -export async function POST( - request: NextRequest, - context: { params: Promise<{ id: string }> } -) { - const { id } = await context.params; - const parsed = reviewSpacedRepetitionCardSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) return NextResponse.json({ error: "Invalid review" }, { status: 400 }); - try { - return NextResponse.json( - await spacedRepetitionService.reviewCard({ setId: id, ...parsed.data }) - ); - } catch (error) { - return spacedRepetitionErrorResponse(error); - } -} diff --git a/src/app/api/spaced-repetition-sets/[id]/route.ts b/src/app/api/spaced-repetition-sets/[id]/route.ts deleted file mode 100644 index 721f616..0000000 --- a/src/app/api/spaced-repetition-sets/[id]/route.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { updateSpacedRepetitionSetSchema } from "@/lib/validation/spacedRepetitionSchemas"; -import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi"; -import * as spacedRepetitionService from "@/services/spacedRepetitionService"; - -export async function GET( - _request: NextRequest, - context: { params: Promise<{ id: string }> } -) { - const { id } = await context.params; - try { - return NextResponse.json(await spacedRepetitionService.getSet(id)); - } catch (error) { - return spacedRepetitionErrorResponse(error); - } -} - -export async function PATCH( - request: NextRequest, - context: { params: Promise<{ id: string }> } -) { - const { id } = await context.params; - const parsed = updateSpacedRepetitionSetSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) return NextResponse.json({ error: "Invalid update" }, { status: 400 }); - try { - return NextResponse.json(await spacedRepetitionService.updateSet(id, parsed.data)); - } catch (error) { - return spacedRepetitionErrorResponse(error); - } -} - -export async function DELETE( - _request: NextRequest, - context: { params: Promise<{ id: string }> } -) { - const { id } = await context.params; - try { - await spacedRepetitionService.deleteSet(id); - return NextResponse.json({ success: true }); - } catch (error) { - return spacedRepetitionErrorResponse(error); - } -} diff --git a/src/app/api/spaced-repetition-sets/[id]/study/route.ts b/src/app/api/spaced-repetition-sets/[id]/study/route.ts deleted file mode 100644 index 5addeed..0000000 --- a/src/app/api/spaced-repetition-sets/[id]/study/route.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi"; -import * as spacedRepetitionService from "@/services/spacedRepetitionService"; - -export async function GET( - _request: NextRequest, - context: { params: Promise<{ id: string }> } -) { - const { id } = await context.params; - try { - return NextResponse.json(await spacedRepetitionService.getStudyState(id)); - } catch (error) { - return spacedRepetitionErrorResponse(error); - } -} diff --git a/src/app/api/spaced-repetition-sets/route.ts b/src/app/api/spaced-repetition-sets/route.ts deleted file mode 100644 index bcc2ec3..0000000 --- a/src/app/api/spaced-repetition-sets/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { createSpacedRepetitionSetSchema } from "@/lib/validation/spacedRepetitionSchemas"; -import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi"; -import * as spacedRepetitionService from "@/services/spacedRepetitionService"; - -export async function GET(request: NextRequest) { - const classId = new URL(request.url).searchParams.get("classId"); - if (!classId) return NextResponse.json({ error: "classId is required" }, { status: 400 }); - try { - return NextResponse.json(await spacedRepetitionService.listSetsByClass(classId)); - } catch (error) { - return spacedRepetitionErrorResponse(error); - } -} - -export async function POST(request: NextRequest) { - const parsed = createSpacedRepetitionSetSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid repetition set" }, { status: 400 }); - } - try { - return NextResponse.json(await spacedRepetitionService.createSet(parsed.data), { - status: 201, - }); - } catch (error) { - return spacedRepetitionErrorResponse(error); - } -} diff --git a/src/app/globals.css b/src/app/globals.css index 75eeb37..53aab02 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -170,537 +170,6 @@ .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; -} -.connections-hub.connections-world::before, -.connections-stage.connections-world::before { display: none; } - -.connections-world::before { - content: ""; - position: absolute; - inset: 0; - z-index: -1; - opacity: .13; - background-image: - linear-gradient(rgba(255,255,255,.18) 1px, transparent 1px), - linear-gradient(90deg, rgba(255,255,255,.18) 1px, transparent 1px); - background-size: 42px 42px; - mask-image: linear-gradient(to bottom, black, transparent 78%); -} - -.connections-world .editorial-title { - font-family: var(--font-manrope), ui-sans-serif, system-ui, sans-serif; - font-weight: 850; - letter-spacing: -.045em; -} - -.connections-hub-hero, -.connections-topbar, -.connections-status, -.connections-setup, -.connections-import { - backdrop-filter: blur(18px); - box-shadow: inset 0 1px rgba(255,255,255,.08), 0 18px 45px rgba(2, 8, 23, .22); -} - -.connections-hub-hero { - background: linear-gradient(120deg, rgba(34,49,84,.94), rgba(24,36,67,.72)); - border: 1px solid rgba(151, 174, 221, .18); -} - -.connections-pack-card { - border-color: rgba(117, 141, 187, .28); - background: rgba(24, 36, 67, .74); - box-shadow: inset 0 1px rgba(255,255,255,.05), 0 12px 30px rgba(3, 9, 24, .16); -} - -.connections-pack-card:hover { - transform: translateY(-2px); - border-color: rgba(255, 209, 102, .48); -} - -.connections-pack-card.is-active { - border-color: #ffd166; - background: linear-gradient(120deg, rgba(65, 72, 132, .9), rgba(38, 55, 96, .9)); - box-shadow: 0 0 0 1px rgba(255,209,102,.25), 0 18px 45px rgba(3,9,24,.25); -} - -.connections-primary-button { - color: #172033; - background: linear-gradient(135deg, #ffd166, #ffb84d); - box-shadow: 0 10px 26px rgba(255, 184, 77, .24), inset 0 1px rgba(255,255,255,.5); - transition: transform .18s var(--ease-spring), filter .18s ease, box-shadow .18s ease; -} - -.connections-primary-button:not(:disabled):hover { - transform: translateY(-2px) scale(1.01); - filter: brightness(1.06); - box-shadow: 0 14px 34px rgba(255, 184, 77, .32), inset 0 1px rgba(255,255,255,.6); -} - -.connections-secondary-button { - color: #dbe7ff; - border: 1px solid #52668d; - background: rgba(24, 36, 67, .82); - transition: transform .18s var(--ease-spring), background .18s ease; -} - -.connections-secondary-button:not(:disabled):hover { transform: translateY(-2px); background: #2c3d68; } - -.connections-tile { - color: #15213a; - border: 1px solid rgba(255,255,255,.75); - background: linear-gradient(145deg, #fffaf0, #e9effa); - box-shadow: 0 7px 0 #aebbd2, 0 12px 24px rgba(2,8,23,.24), inset 0 1px #fff; - animation: connectionsTileIn .36s var(--ease-spring) both; - transition: transform .16s var(--ease-spring), box-shadow .16s ease, color .16s ease, background .16s ease; -} - -.connections-tile:hover { transform: translateY(-3px); box-shadow: 0 9px 0 #aebbd2, 0 16px 30px rgba(2,8,23,.28), inset 0 1px #fff; } -.connections-tile.is-selected { - color: #fff; - border-color: #8c7cf7; - background: linear-gradient(145deg, #735df2, #5540ca); - box-shadow: 0 5px 0 #30228e, 0 12px 28px rgba(84,64,202,.4), inset 0 1px rgba(255,255,255,.28); - transform: translateY(2px) scale(.975); - animation: connectionsTileSelect .25s var(--ease-spring); -} - -.connections-group, -.connections-review-card { - color: #172033; - box-shadow: inset 0 1px rgba(255,255,255,.55), 0 10px 26px rgba(2,8,23,.2); -} -.connections-group .text-text-heading, -.connections-group .text-text-secondary, -.connections-review-card .text-text-heading, -.connections-review-card .text-text-secondary { color: #172033; } -.connections-group { animation: connectionsGroupReveal .56s var(--ease-spring) both; } -.connections-group-0 { background: linear-gradient(135deg, #ffd166, #f7b944); } -.connections-group-1 { background: linear-gradient(135deg, #70e1a1, #36bd7c); } -.connections-group-2 { background: linear-gradient(135deg, #72d5f7, #4aa8e8); } -.connections-group-3 { background: linear-gradient(135deg, #b9a2ff, #8d73ed); } - -.connections-results-hero { - background: linear-gradient(130deg, #5b46d8, #283d77 58%, #164e63); - box-shadow: inset 0 1px rgba(255,255,255,.18), 0 22px 50px rgba(2,8,23,.3); -} - -.connections-completion { - position: relative; - display: grid; - min-height: 32rem; - place-content: center; - overflow: hidden; - border-radius: 1.75rem; - text-align: center; - background: radial-gradient(circle, rgba(94,234,212,.2), transparent 42%), rgba(10,18,39,.72); - animation: connectionsCompletionIn .55s var(--ease-spring) both; -} -.connections-completion-mark { - display: grid; - width: 6rem; - height: 6rem; - margin: 0 auto 1.5rem; - place-items: center; - border-radius: 999px; - color: #172033; - background: #ffd166; - box-shadow: 0 0 0 12px rgba(255,209,102,.12), 0 0 65px rgba(255,209,102,.46); - font-size: 3rem; - font-weight: 900; - animation: connectionsWinMark .8s .15s var(--ease-spring) both; -} -.connections-completion p { color: #8ee7d1; font-size: .75rem; font-weight: 850; letter-spacing: .2em; text-transform: uppercase; } -.connections-completion h2 { margin-top: .35rem; color: white; font-size: clamp(2.5rem, 8vw, 5.5rem); font-weight: 900; letter-spacing: -.065em; line-height: .95; } -.connections-completion span { margin-top: 1rem; color: #b9c8e5; font-weight: 700; } - -.connections-confetti { position: absolute; inset: 0; pointer-events: none; } -.connections-confetti i { - position: absolute; - top: -8%; - left: var(--confetti-x); - width: 9px; - height: 17px; - border-radius: 2px; - background: var(--confetti-color); - animation: connectionsConfettiFall 1.45s var(--confetti-delay) cubic-bezier(.2,.7,.3,1) both; -} - -@keyframes connectionsTileIn { from { opacity: 0; transform: translateY(16px) scale(.94); } to { opacity: 1; transform: translateY(0) scale(1); } } -@keyframes connectionsTileSelect { 0% { transform: scale(1); } 55% { transform: translateY(3px) scale(.94); } 100% { transform: translateY(2px) scale(.975); } } -@keyframes connectionsShake { 0%,100% { transform: translateX(0); } 20% { transform: translateX(-9px); } 40% { transform: translateX(8px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(3px); } } -@keyframes connectionsGroupReveal { from { opacity: 0; transform: scale(.9) translateY(18px); filter: brightness(1.6); } 65% { transform: scale(1.025) translateY(-2px); } to { opacity: 1; transform: scale(1) translateY(0); filter: brightness(1); } } -@keyframes connectionsCompletionIn { from { opacity: 0; transform: scale(.94); } to { opacity: 1; transform: scale(1); } } -@keyframes connectionsWinMark { from { opacity: 0; transform: scale(.2) rotate(-25deg); } 70% { transform: scale(1.12) rotate(4deg); } to { opacity: 1; transform: scale(1) rotate(0); } } -@keyframes connectionsConfettiFall { 0% { opacity: 0; transform: translateY(-1rem) rotate(0); } 10% { opacity: 1; } 100% { opacity: 1; transform: translateY(38rem) rotate(var(--confetti-spin)); } } -@keyframes connectionsResultsIn { from { opacity: 0; transform: translateY(22px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } } -.animate-connections-shake { animation: connectionsShake .42s ease-in-out; } -.animate-connections-results-in { animation: connectionsResultsIn .52s var(--ease-spring) both; } - -/* Crossword turns the full arcade shell into a warm, ink-and-paper newsroom. */ -body:has(.crossword-world) { - --theme-bg-base: #20170f; - --theme-bg-surface: #f6ecd2; - --theme-bg-surface-alt: #ead9b5; - --theme-bg-callout: #dfc795; - --theme-primary: #9a5a24; - --theme-primary-hover: #7b431b; - --theme-text-heading: #2d2117; - --theme-text-body: #493725; - --theme-text-secondary: #68523a; - --theme-text-muted: #8a7052; - --theme-border: #a9865d; - --theme-border-light: #cfb88e; - background: - radial-gradient(circle at 18% 8%, rgba(230, 181, 88, .18), transparent 28rem), - linear-gradient(135deg, #1d140d, #3c2818 55%, #21160e); -} -body:has(.crossword-world)::before { - opacity: .16; - background-image: repeating-linear-gradient(0deg, transparent 0 3px, rgba(255,236,195,.16) 4px); -} -body:has(.crossword-world) .app-sidebar, -body:has(.crossword-world) .app-mobile-header { - --theme-bg-surface-alt: #3a2819; - --theme-bg-callout: #e4cc98; - --theme-primary: #8a4f20; - --theme-text-heading: #fff3d8; - --theme-text-secondary: #d7c3a2; - --theme-text-muted: #aa9170; - --theme-border-light: #5b4028; - color: #f8ead0; - border-color: rgba(224, 190, 126, .22); - background: rgba(35, 23, 14, .94); -} - -.crossword-world { - position: relative; - color: var(--theme-text-body); -} -.crossword-hub.crossword-world, -.crossword-stage.crossword-world { overflow: visible; } -.crossword-hero, -.crossword-setup, -.crossword-active-clue, -.crossword-clues, -.crossword-import, -.crossword-paper { - border: 1px solid rgba(100, 68, 35, .24); - background: - linear-gradient(rgba(255,255,255,.18), rgba(255,255,255,0)), - repeating-linear-gradient(0deg, rgba(83,54,24,.025) 0 1px, transparent 1px 4px), - #f4e7c9; - box-shadow: inset 0 1px rgba(255,255,255,.7), 0 18px 48px rgba(15,9,4,.25); -} -.crossword-hero { position: relative; overflow: hidden; } -.crossword-hero > * { position: relative; z-index: 1; } -.crossword-hero::after { - content: "DAILY STUDY"; - position: absolute; - right: 1rem; - top: .35rem; - color: rgba(77,51,28,.07); - font-family: Georgia, serif; - font-size: clamp(2.75rem, 5vw, 5rem); - font-weight: 900; - letter-spacing: -.06em; - line-height: 1; - pointer-events: none; -} -.crossword-kicker, -.crossword-section-label { - color: #925623; - font-size: .68rem; - font-weight: 900; - letter-spacing: .18em; - text-transform: uppercase; -} -.crossword-section-label { display: block; margin-bottom: .7rem; color: var(--theme-text-muted); } -.crossword-primary { - color: #fff8e7; - background: linear-gradient(135deg, #a86429, #744018); - box-shadow: inset 0 1px rgba(255,255,255,.24), 0 7px 0 #4a2912, 0 12px 24px rgba(49,27,12,.22); - transition: transform .16s var(--ease-spring), filter .16s ease; -} -.crossword-primary:hover { transform: translateY(-2px); filter: brightness(1.08); } -.crossword-primary:active { transform: translateY(4px); box-shadow: inset 0 1px rgba(255,255,255,.2), 0 3px 0 #4a2912; } -.crossword-pack { - border-color: rgba(111,76,43,.25); - background: rgba(244,231,201,.92); - box-shadow: 0 10px 26px rgba(20,12,6,.16), inset 0 1px rgba(255,255,255,.65); - transition: transform .18s ease, border-color .18s ease; -} -.crossword-pack:hover { transform: translateY(-2px); border-color: #a86429; } -.crossword-pack.is-active { border-color: #8d511f; box-shadow: 0 0 0 2px rgba(168,100,41,.2), 0 14px 30px rgba(20,12,6,.2); } -.crossword-paper > div i { aspect-ratio: 1; border: 1px solid rgba(45,33,23,.3); } -.crossword-size { - color: var(--theme-text-secondary); - border: 1px solid var(--theme-border-light); - background: rgba(255,250,235,.48); -} -.crossword-size.is-active { color: #2d2117; border-color: #955720; background: #e4c993; box-shadow: inset 0 0 0 2px rgba(255,255,255,.35); } -.crossword-board-preview { overflow: hidden; border: 1px solid #c9aa78; border-radius: .9rem; background: rgba(255,250,235,.48); } -.crossword-preview-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .65rem .8rem; border-bottom: 1px solid #d5bd93; color: #5d4630; } -.crossword-preview-heading span { font-size: .68rem; font-weight: 900; letter-spacing: .14em; text-transform: uppercase; } -.crossword-preview-heading small { color: #8a7052; font-size: .68rem; } -.crossword-preview-canvas { display: grid; min-height: 9rem; place-items: center; overflow: hidden; padding: .75rem; background: #322116; } -.crossword-preview-grid { display: grid; filter: drop-shadow(0 6px 9px rgba(0,0,0,.35)); } -.crossword-preview-grid i { border: .5px solid #9e8058; background: #fff6de; } -.crossword-preview-loading,.crossword-preview-canvas > p { color: #d7c3a2; font-size: .75rem; font-weight: 800; } -.crossword-option { display: flex; align-items: flex-start; gap: .7rem; padding: .75rem; border-radius: .75rem; background: rgba(224,202,157,.38); } -.crossword-option input { margin-top: .2rem; accent-color: #925623; } -.crossword-option strong,.crossword-option small { display: block; } -.crossword-option small { margin-top: .12rem; color: var(--theme-text-muted); } -.crossword-active-clue { display: grid; gap: .2rem; } -.crossword-active-clue span { color: #925623; font-size: .68rem; font-weight: 900; letter-spacing: .15em; text-transform: uppercase; } -.crossword-active-clue strong { color: #2d2117; font-family: Georgia, serif; font-size: 1.05rem; } -.crossword-active-clue small { color: var(--theme-text-muted); } -.crossword-toolbar { color: #f4e7c9; background: rgba(35,23,14,.9); box-shadow: 0 8px 24px rgba(13,8,4,.24); } -.crossword-toolbar > div { display: flex; align-items: center; gap: .35rem; } -.crossword-toolbar button { display: grid; width: 2rem; height: 2rem; place-items: center; border: 1px solid rgba(245,222,179,.25); border-radius: .45rem; font-weight: 900; } -.crossword-toolbar span { min-width: 3rem; text-align: center; font-size: .75rem; font-weight: 800; } -.crossword-board-viewport { - max-height: min(68vh, 46rem); - overflow: auto; - padding: 1rem; - overscroll-behavior: contain; - background: #2a1d13; - box-shadow: inset 0 0 0 5px #4c321e, inset 0 0 28px rgba(0,0,0,.7), 0 16px 38px rgba(13,8,4,.3); -} -.crossword-board { display: grid; width: max-content; min-width: 100%; min-height: 20rem; place-content: center; } -.crossword-cell { - position: relative; - display: grid; - width: var(--crossword-cell); - height: var(--crossword-cell); - place-items: center; - color: #25190f; - border: 1px solid #6f5335; - background: #fff8e6; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - transition: background .12s ease, box-shadow .12s ease; -} -.crossword-cell small { position: absolute; left: 2px; top: 0; font-size: max(7px, calc(var(--crossword-cell) * .22)); line-height: 1; } -.crossword-cell strong { font-size: max(12px, calc(var(--crossword-cell) * .56)); } -.crossword-cell.is-word { background: #f2d99f; } -.crossword-cell.is-active { z-index: 2; background: #ffe27e; box-shadow: inset 0 0 0 3px #925623; } -.crossword-cell.is-wrong::after { content: ""; position: absolute; right: 3px; bottom: 3px; width: 7px; height: 7px; border: 2px solid #a62e25; border-radius: 50%; } -.crossword-cell.is-revealed strong { color: #8a5a24; text-decoration: underline dotted; } -.crossword-secondary { - min-height: 2.75rem; - padding: .6rem .9rem; - border: 1px solid #9f7a50; - border-radius: .7rem; - color: #392718; - background: #ead6ae; - font-size: .78rem; - font-weight: 850; - box-shadow: 0 3px 0 #aa895f; -} -.crossword-secondary:hover { background: #f5e5c4; } -.crossword-clues { max-height: 72vh; overflow: hidden; } -.crossword-clues > div button { min-height: 2.5rem; border-bottom: 2px solid transparent; color: var(--theme-text-muted); font-size: .75rem; font-weight: 900; text-transform: uppercase; } -.crossword-clues > div button.is-active { color: #7d461d; border-color: #9a5a24; } -.crossword-clues ol { max-height: calc(72vh - 5rem); overflow-y: auto; padding-right: .25rem; } -.crossword-clues li button { display: grid; width: 100%; grid-template-columns: 2rem 1fr; gap: .4rem; padding: .55rem; border-radius: .5rem; text-align: left; color: #4b3826; } -.crossword-clues li button:hover,.crossword-clues li button.is-active { background: #dec38c; } -.crossword-clues li b { color: #8a4f20; } -.crossword-mobile-input { position: fixed; width: 1px; height: 1px; opacity: 0; pointer-events: none; } -.crossword-results-hero { color: #f8ead0; background: linear-gradient(135deg, #3b2819, #6d421f); box-shadow: 0 18px 45px rgba(15,9,4,.3); } -.crossword-results-hero > div > div { background: rgba(255,244,220,.1); } -.crossword-results-hero small,.crossword-results-hero strong { display: block; } -.crossword-results-hero small { color: rgba(255,244,220,.6); font-size: .65rem; font-weight: 900; text-transform: uppercase; } -.crossword-results-hero strong { margin-top: .2rem; font-size: 1.25rem; } -.crossword-final-board { border: 1px solid #c8aa79; color: #392718; background: #f4e7c9; box-shadow: 0 12px 32px rgba(20,12,6,.18); } -.crossword-final-legend { display: flex; flex-wrap: wrap; gap: .8rem; color: #6a5139; font-size: .72rem; font-weight: 800; } -.crossword-final-legend span { display: inline-flex; align-items: center; gap: .35rem; } -.crossword-final-legend i { width: .8rem; height: .8rem; border: 1px solid currentColor; border-radius: .15rem; } -.crossword-final-legend i.is-correct { color: #356b3b; background: #bfe0b9; } -.crossword-final-legend i.is-assisted { color: #8a631c; background: #f2d47e; } -.crossword-final-legend i.is-incorrect { color: #923a30; background: #efb9af; } -.crossword-final-viewport { overflow: auto; padding: 1rem; border-radius: 1rem; background: #2a1d13; box-shadow: inset 0 0 22px rgba(0,0,0,.55); } -.crossword-final-grid { display: grid; width: max-content; min-width: 100%; place-content: center; } -.crossword-final-cell { position: relative; display: grid; place-items: center; color: #25190f; border: 1px solid #62482f; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } -.crossword-final-cell.is-correct { background: #c7e6bf; box-shadow: inset 0 0 0 1px rgba(47,108,58,.35); } -.crossword-final-cell.is-assisted { background: #f2d47e; box-shadow: inset 0 0 0 2px rgba(138,99,28,.42); } -.crossword-final-cell.is-incorrect { background: #efbbb1; box-shadow: inset 0 0 0 2px rgba(145,47,39,.45); } -.crossword-final-cell small { position: absolute; left: 2px; top: 1px; font-size: 7px; line-height: 1; } -.crossword-final-cell strong { font-size: clamp(10px, 2vw, 15px); } -.crossword-review { border: 1px solid #c8aa79; color: #392718; background: #f4e7c9; box-shadow: 0 8px 22px rgba(20,12,6,.15); } -.crossword-review.is-incorrect { border-left: 6px solid #a63a2e; } -.crossword-review.is-correct { border-left: 6px solid #4f7a4f; } -.crossword-review.is-assisted { border-left: 6px solid #b58627; } -.crossword-review.is-omitted { opacity: .75; border-left: 6px solid #8c765a; } - -@media (max-width: 639px) { - .crossword-board-viewport { margin-inline: -.25rem; padding: .6rem; } - .crossword-board { place-content: start; min-height: 24rem; } - .crossword-clues { max-height: 28rem; } - .crossword-hero::after { top: .75rem; right: .75rem; font-size: 2.75rem; } - .crossword-final-viewport { margin-inline: -.25rem; padding: .65rem; } -} - .markdown-content h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: .5rem; color: var(--color-text-heading); } .markdown-content h2 { font-size: 1.25rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); } .markdown-content h3 { font-size: 1.125rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6fbaecf..f92c5fb 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Manrope, Newsreader } from "next/font/google"; +import Script from "next/script"; import "./globals.css"; const manrope = Manrope({ @@ -25,12 +26,10 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - - {children} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 32ce56c..30b8ab0 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,333 +1,158 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; import { ThemeToggle } from "@/components/ui/ThemeToggle"; -type LoginStage = "login" | "token" | "password" | "success"; - -async function postJson(path: string, body?: object) { - const response = await fetch(path, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body ?? {}), - }); - const data: { error?: string } = await response.json(); - if (!response.ok) throw new Error(data.error || "Request failed"); -} - export default function LoginPage() { - const [stage, setStage] = useState("login"); const [password, setPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [token, setToken] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); - const [setupRequired, setSetupRequired] = useState(null); + const [isSetup, setIsSetup] = useState(null); + const router = useRouter(); useEffect(() => { fetch("/api/auth/setup-status") - .then((response) => response.json()) - .then((data) => setSetupRequired(data.setupRequired)) - .catch(() => setSetupRequired(false)); + .then((res) => res.json()) + .then((data) => setIsSetup(data.setupRequired)) + .catch(() => setIsSetup(false)); // fallback }, []); - async function run(action: () => Promise) { + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); setError(""); setLoading(true); + try { - await action(); - } catch (caught) { - setError(caught instanceof Error ? caught.message : "Network error. Please try again."); + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + + const data = await res.json(); + + if (!res.ok) { + setError(data.error || "Login failed"); + return; + } + + window.location.href = "/"; + } catch { + setError("Network error. Please try again."); } finally { setLoading(false); } } - function handleLogin(event: React.FormEvent) { - event.preventDefault(); - void run(async () => { - await postJson("/api/auth/login", { password }); - window.location.href = "/"; - }); - } - - function beginReset() { - void run(async () => { - await postJson("/api/auth/password-reset/request"); - setToken(""); - setStage("token"); - }); - } - - function handleToken(event: React.FormEvent) { - event.preventDefault(); - void run(async () => { - await postJson("/api/auth/password-reset/verify", { token: token.trim() }); - setPassword(""); - setConfirmPassword(""); - setStage("password"); - }); - } - - function handlePasswordReset(event: React.FormEvent) { - event.preventDefault(); - void run(async () => { - await postJson("/api/auth/password-reset/complete", { - password, - confirmPassword, - }); - setToken(""); - setPassword(""); - setConfirmPassword(""); - setStage("success"); - }); - } - - function returnToLogin() { - setStage("login"); - setError(""); - setToken(""); - setPassword(""); - setConfirmPassword(""); - } - - if (setupRequired === null) { + if (isSetup === null) { return (
-
+
+
+
); } - const title = - stage === "token" - ? "Enter reset token" - : stage === "password" - ? "Choose a new password" - : stage === "success" - ? "Password updated" - : setupRequired - ? "Welcome to Study Desk" - : "Welcome back"; - - const description = - stage === "token" - ? "Find the token in the Study Desk Docker logs. It expires after 15 minutes." - : stage === "password" - ? "Use at least eight characters and enter the password twice." - : stage === "success" - ? "Your old password and existing sessions are no longer valid." - : setupRequired - ? "Please set your admin password for the first time." - : "Open your notes, decks, and practice quizzes."; - return (
- -
-
+
+ {/* Logo / Title area */} +
S

Personal workspace

-

{title}

-

{description}

-
- -
- {stage === "login" && ( -
- - - - {setupRequired ? "Save Password & Login" : "Sign In"} - - {!setupRequired && ( - - )} - - )} - - {stage === "token" && ( -
-
- - setToken(event.target.value)} - autoComplete="one-time-code" - autoFocus - className="min-h-12 w-full rounded-xl border border-border bg-bg-surface-alt/60 px-4 font-mono text-text-heading placeholder:text-text-muted focus:border-primary" - placeholder="Paste the token from Docker logs" - /> -
- - Validate Token - Back to sign in - - )} - - {stage === "password" && ( -
-
- - = 8} - validText="Password meets the length requirement." - invalidText="Password must be at least 8 characters." - /> -
-
- 0 && confirmPassword !== password} - /> - {confirmPassword && ( - - )} -
- - - Set New Password - - Cancel - - )} - - {stage === "success" && ( -
-
- Your password has been reset successfully. -
- Sign In -
- )} +

+ {isSetup ? "Welcome to Study Desk" : "Welcome back"} +

+

+ {isSetup + ? "Please set your admin password for the first time." + : "Open your notes, decks, and practice quizzes."} +

-
+ + {/* Login card */} +
+
+
+ + setPassword(e.target.value)} + placeholder={isSetup ? "Create a new password" : "Enter your password"} + autoFocus + className="min-h-12 w-full rounded-xl border border-border bg-bg-surface-alt/60 px-4 text-text-heading placeholder:text-text-muted focus:border-primary" + /> +
+ + {error && ( +
+ + + + {error} +
+ )} + + +
+
+
); } - -function PasswordField({ - id, - label, - value, - onChange, - placeholder, - describedBy, - invalid = false, - autoFocus = false, -}: { - id: string; - label: string; - value: string; - onChange: (value: string) => void; - placeholder?: string; - describedBy?: string; - invalid?: boolean; - autoFocus?: boolean; -}) { - return ( -
- - onChange(event.target.value)} - placeholder={placeholder} - autoComplete={id === "password" ? "current-password" : "new-password"} - aria-describedby={describedBy} - aria-invalid={invalid} - autoFocus={autoFocus} - className="min-h-12 w-full rounded-xl border border-border bg-bg-surface-alt/60 px-4 text-text-heading placeholder:text-text-muted focus:border-primary" - /> -
- ); -} - -function ValidationMessage({ id, valid, validText, invalidText }: { - id: string; - valid: boolean; - validText: string; - invalidText: string; -}) { - return ( -

- {valid ? validText : invalidText} -

- ); -} - -function ErrorMessage({ message }: { message: string }) { - if (!message) return null; - return
{message}
; -} - -function PrimaryButton({ loading, disabled = false, onClick, children }: { - loading: boolean; - disabled?: boolean; - onClick?: () => void; - children: React.ReactNode; -}) { - return ( - - ); -} - -function SecondaryButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) { - return ( - - ); -} diff --git a/src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx b/src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx index 5381446..a49d137 100644 --- a/src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx +++ b/src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx @@ -4,9 +4,8 @@ import { useState, useEffect } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import type { SharedGroupData } from "@/types/study"; -export function SharedGroupViewer({ data }: { data: SharedGroupData }) { +export function SharedGroupViewer({ data }: { data: any }) { const pathname = usePathname(); const router = useRouter(); @@ -19,7 +18,7 @@ export function SharedGroupViewer({ data }: { data: SharedGroupData }) { if (typeof window === 'undefined') return; const newProgress: Record = {}; - items.forEach((item) => { + items.forEach((item: any) => { const key = data.type === "DECK" ? `flashcard_progress_${item.id}` : `quiz_progress_${item.id}`; const saved = localStorage.getItem(key); if (saved) { @@ -72,7 +71,7 @@ export function SharedGroupViewer({ data }: { data: SharedGroupData }) {
- {items.map((item) => ( + {items.map((item: any) => (
q.category.toUpperCase()))).join(" · ") + ? Array.from(new Set(data.questions.map((q: any) => q.category.toUpperCase()))).join(" · ") : ""; return ( @@ -138,20 +137,18 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp view === "study" ? ( ) : ( - + ) ) : ( d.id === itemId) ?? null; + targetData = link.group.decks.find(d => d.id === itemId); targetType = "flashcards"; } else { - targetData = link.group.quizSets.find(q => q.id === itemId) ?? null; + targetData = link.group.quizSets.find(q => q.id === itemId); targetType = "quizzes"; } if (!targetData) notFound(); @@ -72,8 +69,6 @@ export default async function SharedPage(props: SharedPageProps & { searchParams targetData = type === "flashcards" ? link.deck : link.quizSet; } - if (!targetData) notFound(); - return (
{/* Read-only Header */} @@ -110,11 +105,11 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
{type === "groups" && !itemId ? ( - + ) : ( )} diff --git a/src/components/activity/ActivityBanner.tsx b/src/components/activity/ActivityBanner.tsx deleted file mode 100644 index 0f15abb..0000000 --- a/src/components/activity/ActivityBanner.tsx +++ /dev/null @@ -1,188 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useRef, useState } from "react"; - -interface DailyActivity { - date: string; - flashcards: number; - questions: number; - arcade: number; - total: number; - level: 0 | 1 | 2 | 3 | 4; -} - -interface ActivitySummary { - days: DailyActivity[]; - today: string; - currentStreak: number; -} - -const levelClasses = [ - "bg-white/10", - "bg-primary/45", - "bg-primary", - "bg-accent", - "bg-[#fbbf24]", -] as const; - -export function ActivityBanner({ onNewClass }: { onNewClass: () => void }) { - const [summary, setSummary] = useState(null); - const [error, setError] = useState(false); - - useEffect(() => { - fetch("/api/activity") - .then((response) => { - if (!response.ok) throw new Error("Failed to load activity"); - return response.json(); - }) - .then(setSummary) - .catch(() => setError(true)); - }, []); - - return ( -
-
-
-
-
-

Personal library

-

Study activity

-
- -
- - {error && ( -
- Study activity could not be loaded. Your library is still available below. -
- )} - {!summary && !error &&
} - {summary && ( -
-
- -
- -
- )} -
- ); -} - -function ActivityHeatmap({ days, today }: { days: DailyActivity[]; today: string }) { - const scrollRef = useRef(null); - const [activeDay, setActiveDay] = useState(null); - const weeks = useMemo( - () => Array.from({ length: 53 }, (_, index) => days.slice(index * 7, index * 7 + 7)), - [days] - ); - - useEffect(() => { - const container = scrollRef.current; - if (container) container.scrollLeft = container.scrollWidth; - }, [days]); - - return ( -
-
-

Last 53 weeks · Arizona time

-
- {activeDay && } -
-
-
-
-
-
-
Mon
-
-
Wed
-
-
Fri
-
-
-
- {weeks.map((week, index) => ( -
- {getMonthLabel(week, weeks[index - 1])} -
- ))} -
-
- {weeks.map((week, weekIndex) => ( -
- {week.map((day) => day.date > today ? ( - - ) : ( -
- ))} -
-
-
-
-
- Less - {levelClasses.map((className, index) => )} - More -
-
- ); -} - -function DayTooltip({ day }: { day: DailyActivity }) { - const formatted = new Date(`${day.date}T12:00:00Z`).toLocaleDateString("en-US", { - timeZone: "America/Phoenix", - month: "short", - day: "numeric", - year: "numeric", - }); - return ( -
-
{formatted} · {day.total} total
-
{day.flashcards} flashcards · {day.questions} questions · {day.arcade} arcade groups
-
- ); -} - -function StreakDisplay({ streak }: { streak: number }) { - const size = 24 + Math.min(streak, 30) * 0.6; - const color = streak === 0 ? "text-white/30" : streak < 7 ? "text-accent" : streak < 30 ? "text-[#ff7a36]" : "text-[#fbbf24]"; - return ( -
- - - -
-
{streak}
-
day streak
-
20 activities per day
-
-
- ); -} - -function getMonthLabel(week: DailyActivity[], previousWeek?: DailyActivity[]) { - const firstOfMonth = week.find((day) => Number(day.date.slice(8, 10)) <= 7); - if (!firstOfMonth) return ""; - const month = firstOfMonth.date.slice(5, 7); - if (previousWeek?.some((day) => day.date.slice(5, 7) === month)) return ""; - return new Date(`${firstOfMonth.date}T12:00:00Z`).toLocaleDateString("en-US", { month: "short", timeZone: "America/Phoenix" }); -} - -function getDayAriaLabel(day: DailyActivity) { - return `${day.date}: ${day.flashcards} flashcards, ${day.questions} questions, and ${day.arcade} arcade groups, ${day.total} total activities`; -} diff --git a/src/components/arcade/ArcadeGameShell.tsx b/src/components/arcade/ArcadeGameShell.tsx deleted file mode 100644 index bc63241..0000000 --- a/src/components/arcade/ArcadeGameShell.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; -import { useRouter } from "next/navigation"; - -export function ArcadeGameShell({ - title, - exitHref, - elapsedSeconds, - complete, - worldClassName = "connections-world connections-stage", - children, -}: { - title: string; - exitHref: string; - elapsedSeconds: number; - complete: boolean; - worldClassName?: string; - children: ReactNode; -}) { - const router = useRouter(); - function exitGame() { - if (!complete && !window.confirm("Leave this round? Your progress will not be saved.")) return; - router.push(exitHref); - } - const minutes = Math.floor(elapsedSeconds / 60); - const seconds = String(elapsedSeconds % 60).padStart(2, "0"); - return ( -
-
- -

{title}

-
{minutes}:{seconds}
-
- {children} -
- ); -} diff --git a/src/components/arcade/ArcadeImportModal.tsx b/src/components/arcade/ArcadeImportModal.tsx deleted file mode 100644 index 6378d34..0000000 --- a/src/components/arcade/ArcadeImportModal.tsx +++ /dev/null @@ -1,127 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { GenerateTab } from "@/components/import/GenerateTab"; -import type { ArcadeGameKey, ArcadeImportBatchPreview } from "@/types/arcade"; - -export function ArcadeImportModal({ - classId, - gameType, - onClose, - onImported, -}: { - classId: string; - gameType: ArcadeGameKey; - onClose: () => void; - onImported: () => void; -}) { - const [tab, setTab] = useState<"generate" | "import">("import"); - const [rawJson, setRawJson] = useState(""); - const [names, setNames] = useState([]); - const [preview, setPreview] = useState(null); - const [error, setError] = useState(""); - const [details, setDetails] = useState([]); - const [acknowledged, setAcknowledged] = useState(false); - const [busy, setBusy] = useState(false); - - async function previewImport() { - setBusy(true); - setError(""); - setDetails([]); - setPreview(null); - try { - const response = await fetch("/api/arcade/import/preview", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ gameType, rawJson }), - }); - const data = await response.json(); - if (!response.ok) { - setError(data.error ?? "Import preview failed"); - setDetails(data.details ?? []); - return; - } - setPreview(data); - setNames(data.packs.map((pack: { name: string }) => pack.name)); - } finally { - setBusy(false); - } - } - - async function saveImport() { - if (!preview || names.some((name) => !name.trim())) return; - setBusy(true); - setError(""); - try { - const response = await fetch("/api/arcade/packs", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - classId, - gameType, - rawJson, - ...(gameType === "connections" ? { names: names.map((name) => name.trim()) } : { name: names[0]?.trim() }), - warningsAcknowledged: acknowledged, - }), - }); - const data = await response.json(); - if (!response.ok) { - setError(data.error ?? "Import failed"); - setDetails(data.details ?? []); - return; - } - onImported(); - } finally { - setBusy(false); - } - } - - return ( -
- -
-
- {(["generate", "import"] as const).map((item) => ( - - ))} -
-
- {tab === "generate" ? : ( -
-

{gameType === "connections" ? "Paste one pack object or an array of up to ten packs." : "Paste one Crossword pack containing exactly 80 entries."} Every board is validated before anything is saved.

-