Compare commits
13 commits
backup-mai
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a6af3a49ad | |||
| 5bec95fb30 | |||
| 611a585757 | |||
| 7b90409f2e | |||
| d6f3502cb1 | |||
| 6d1d918355 | |||
| e3265f03e2 | |||
| dad62c9914 | |||
| 95af276d2e | |||
| f0548bcc6e | |||
| 061d13f421 | |||
| c84332d072 | |||
| 631dfa5923 |
126 changed files with 19484 additions and 868 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -13,6 +13,12 @@
|
|||
# testing
|
||||
/coverage
|
||||
|
||||
# local SQLite study data
|
||||
/dev.db
|
||||
/dev.db-journal
|
||||
/dev.db-shm
|
||||
/dev.db-wal
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
|
|
|||
353
AGENTS.md
353
AGENTS.md
|
|
@ -1,5 +1,350 @@
|
|||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
# Study Desk - AI Contributor Guide
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
This file is the operating guide for AI agents and human contributors working in this repository.
|
||||
|
||||
## Project summary
|
||||
|
||||
Study Desk is a self-hosted, single-user study application built with Next.js, React, TypeScript, Prisma, and SQLite.
|
||||
|
||||
Core capabilities:
|
||||
|
||||
- Flashcard decks with Markdown content, self-grading, shuffle persistence, resume state, and card management
|
||||
- Practice quizzes with multiple-choice and SATA questions, partial-credit scoring, rationales, category breakdowns, retakes, and attempt history
|
||||
- JSON import, repair, validation, and generation workflows
|
||||
- Material groups for organizing decks and quizzes
|
||||
- Password-protected private pages
|
||||
- Public, read-only share links for decks, quizzes, and groups
|
||||
- Responsive light and dark themes
|
||||
|
||||
This is intentionally a single-user container, not a multi-tenant SaaS application. There is no `User` model. Content ownership is implicit in the local installation.
|
||||
|
||||
When documentation conflicts with implementation, prefer:
|
||||
|
||||
1. Current source code
|
||||
2. `prisma/schema.prisma`
|
||||
3. Applied migrations
|
||||
4. This file
|
||||
5. Older planning documents and the stock README
|
||||
|
||||
## Mandatory working rules
|
||||
|
||||
- Inspect `git status --short` before editing.
|
||||
- Preserve unrelated user changes.
|
||||
- Keep changes scoped to the requested behavior.
|
||||
- Do not use destructive commands such as `git reset --hard` or `git checkout --` unless explicitly requested.
|
||||
- Prefer narrow, patch-based edits over rewriting entire files.
|
||||
- Do not commit secrets, `.env` files, database contents, generated credentials, or private study material.
|
||||
- Do not manually edit generated Prisma files under `src/generated/prisma/`.
|
||||
- Do not modify an already-applied migration. Change `prisma/schema.prisma`, then create a new migration.
|
||||
- Prefer TypeScript types over `any`.
|
||||
- Add dependencies only when the existing stack cannot reasonably solve the problem.
|
||||
- When dependencies change, update `package-lock.json` and verify the build.
|
||||
- Separate UI, route handling, services, and low-level utilities by responsibility.
|
||||
- Ask a clarifying question only when missing information would materially change the implementation or create a risky assumption.
|
||||
|
||||
## Stack and source of truth
|
||||
|
||||
Confirm exact versions in `package.json`. The project currently uses:
|
||||
|
||||
- Next.js 16 App Router
|
||||
- React 19
|
||||
- TypeScript in strict mode
|
||||
- Tailwind CSS v4
|
||||
- Prisma 7 with SQLite and `@prisma/adapter-better-sqlite3`
|
||||
- `iron-session` for encrypted session cookies
|
||||
- Argon2 password hashing
|
||||
- Zod validation
|
||||
- `react-markdown` with `remark-gfm`
|
||||
- `@dnd-kit` for sortable UI
|
||||
- Node 22 in Docker
|
||||
|
||||
Important files:
|
||||
|
||||
- `package.json` - scripts and dependency versions
|
||||
- `prisma/schema.prisma` - database source of truth
|
||||
- `src/lib/db.ts` - shared Prisma client
|
||||
- `src/lib/auth.ts` - session helpers
|
||||
- `src/services/` - business logic and database access
|
||||
- `src/app/api/` - route handlers
|
||||
- `src/app/globals.css` - design tokens and global styling
|
||||
- `src/config/studyModes.ts` - shared study-mode navigation
|
||||
- `SKILL.md` - project workflow for adding API routes
|
||||
- `study-app-implementation-plan.md` - historical architectural context only
|
||||
- `README.md` - currently not authoritative
|
||||
|
||||
For framework-sensitive changes involving routing, layouts, dynamic params, middleware, server/client boundaries, or framework APIs, follow nearby repository patterns and consult version-matched Next.js documentation. Do not perform unrelated framework migrations.
|
||||
|
||||
## Local development
|
||||
|
||||
For a clean checkout:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npx prisma generate
|
||||
npm run dev
|
||||
```
|
||||
|
||||
On Windows PowerShell, use `npm.cmd` and `npx.cmd` if script execution policy blocks the default shims.
|
||||
|
||||
Useful scripts:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run start
|
||||
npm run lint
|
||||
```
|
||||
|
||||
There is currently no configured test runner. Use focused manual verification plus `npm run build`. Run lint as well, but distinguish pre-existing failures from failures introduced by the current change.
|
||||
|
||||
## Environment and authentication
|
||||
|
||||
Common environment variables:
|
||||
|
||||
- `DATABASE_URL` - SQLite URL
|
||||
- `SESSION_SECRET` - `iron-session` secret, at least 32 characters outside throwaway local development
|
||||
- `SECURE_COOKIES` - marks the session cookie secure when set to `true`
|
||||
- `ADMIN_PASSWORD_HASH` - present in `.env.example`, but currently not used by the login flow
|
||||
|
||||
Current password behavior:
|
||||
|
||||
1. On first login, the submitted password is Argon2-hashed.
|
||||
2. The hash is stored in the `Setting` row `admin_password_hash`.
|
||||
3. Later logins verify against that stored hash.
|
||||
|
||||
Do not change password provisioning casually. Any change must update login, setup-status behavior, deployment documentation, and security handling together.
|
||||
|
||||
Authentication state is stored in the `study-app-session` cookie.
|
||||
|
||||
Public paths include:
|
||||
|
||||
- `/login`
|
||||
- `/api/auth/*`
|
||||
- `/shared/*`
|
||||
- Static assets and Next internals
|
||||
|
||||
Everything else requires authentication unless explicitly designed and reviewed as public.
|
||||
|
||||
Do not log passwords, secrets, full share tokens, or private study content.
|
||||
|
||||
## Database and migration safety
|
||||
|
||||
The local `dev.db` may contain real study data.
|
||||
|
||||
- Do not delete, reset, replace, or recreate it during normal work.
|
||||
- Back up the database before destructive migration work.
|
||||
- Use the shared Prisma client from `@/lib/db`.
|
||||
- Do not create additional `PrismaClient` instances in request handlers or components.
|
||||
- Use transactions when partial writes would leave inconsistent state.
|
||||
- Return 404 responses for missing records instead of exposing raw Prisma errors.
|
||||
|
||||
After changing the Prisma schema:
|
||||
|
||||
```bash
|
||||
npx prisma generate
|
||||
npx prisma migrate dev --name describe_the_change
|
||||
```
|
||||
|
||||
Use `npx prisma migrate deploy` for deployed environments.
|
||||
|
||||
## Architecture conventions
|
||||
|
||||
### Server and client components
|
||||
|
||||
- Use Server Components by default.
|
||||
- Add `"use client"` only when state, effects, event handlers, browser APIs, drag and drop, or client-side fetching are required.
|
||||
- Keep database and authentication modules out of client import graphs.
|
||||
- Do not access `window`, `localStorage`, `navigator`, or clipboard APIs from Server Components.
|
||||
- Prefer `Link` for ordinary internal navigation.
|
||||
- Use `useRouter` when navigation follows an action or must be imperative.
|
||||
- Follow nearby Next.js 16 patterns for dynamic params and route context types.
|
||||
|
||||
### API routes and services
|
||||
|
||||
Preferred route-handler flow:
|
||||
|
||||
1. Parse and validate external input.
|
||||
2. Delegate business logic to focused service or utility functions.
|
||||
3. Return a useful status and JSON response.
|
||||
|
||||
Business logic and Prisma calls normally belong in `src/services/` or a focused `src/lib/` module. Reuse existing services before creating new abstractions.
|
||||
|
||||
Some older routes still contain direct Prisma calls or loose input handling. Improve the requested path without turning a small task into a broad refactor.
|
||||
|
||||
### Validation
|
||||
|
||||
- Reuse existing Zod schemas where available.
|
||||
- Never rely only on client-side validation.
|
||||
- Validate IDs, required strings, enums, arrays, and import payloads at the API boundary.
|
||||
- Keep stored JSON parseable.
|
||||
- Use `jsonRepair.ts` only for user- or LLM-generated import text, not to hide malformed database state.
|
||||
|
||||
## UI and accessibility
|
||||
|
||||
Use the existing design system in `src/app/globals.css`.
|
||||
|
||||
Common tokens include:
|
||||
|
||||
- `bg-bg-base`
|
||||
- `bg-bg-surface`
|
||||
- `bg-bg-surface-alt`
|
||||
- `text-text-heading`
|
||||
- `text-text-secondary`
|
||||
- `text-text-muted`
|
||||
- `primary`
|
||||
- `primary-hover`
|
||||
- `border`
|
||||
- `border-light`
|
||||
- `success`
|
||||
- `error`
|
||||
|
||||
Preserve:
|
||||
|
||||
- Light and dark themes
|
||||
- Responsive mobile layouts
|
||||
- Existing card, modal, shadow, and rounded-corner language
|
||||
- `editorial-title` for major serif headings
|
||||
- Manrope for interface text
|
||||
- Existing Markdown rendering through `ReactMarkdown`, `remarkGfm`, and `.markdown-content`
|
||||
- Existing `@dnd-kit` patterns for sortable content
|
||||
|
||||
Accessibility requirements:
|
||||
|
||||
- Use semantic buttons and links.
|
||||
- Preserve visible keyboard focus states.
|
||||
- Give icon-only controls an accessible name with visible text or `aria-label`.
|
||||
- Keep interactive controls keyboard accessible.
|
||||
- Avoid horizontal overflow on study pages, library cards, modals, and action rows.
|
||||
- Do not attach drag listeners to nested action buttons.
|
||||
|
||||
## Domain invariants
|
||||
|
||||
### Flashcards
|
||||
|
||||
- A deck contains ordered `Flashcard` rows with `front` and `back`.
|
||||
- Study progress stores order and results as JSON.
|
||||
- Study mode may be sequential or shuffled.
|
||||
- Saved progress may contain stale card IDs after deletion. Resume helpers must filter and clamp stale IDs safely.
|
||||
- Preserve both study and manage views.
|
||||
- Preserve existing sharing entry points when adding new ones.
|
||||
|
||||
### Quizzes
|
||||
|
||||
- A quiz contains ordered questions with ordered answer options.
|
||||
- `MULTIPLE_CHOICE` questions must have exactly one correct option.
|
||||
- `SATA` questions may have multiple correct options.
|
||||
- SATA uses partial-credit scoring in `src/lib/scoring.ts`.
|
||||
- Partial retakes score only questions present in the submitted answer set.
|
||||
- Attempts persist score, maximum score, submitted answers, retake state, and completion time.
|
||||
- In-progress answers and position persist through `/api/progress`.
|
||||
|
||||
### Imports and generation
|
||||
|
||||
Flashcard import shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "flashcards",
|
||||
"deckName": "Deck name",
|
||||
"description": "Optional description",
|
||||
"cards": [{ "front": "...", "back": "..." }]
|
||||
}
|
||||
```
|
||||
|
||||
Quiz import shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "quiz",
|
||||
"quizName": "Quiz name",
|
||||
"description": "Optional description",
|
||||
"questions": [
|
||||
{
|
||||
"type": "multiple_choice",
|
||||
"prompt": "...",
|
||||
"category": "topic",
|
||||
"options": [{ "text": "...", "correct": true }],
|
||||
"rationale": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The app does not call an LLM directly. The Generate UI prepares instructions and accepts generated JSON from the user.
|
||||
|
||||
Preserve Markdown inside JSON string values and render it through existing Markdown components.
|
||||
|
||||
Keep the current separation between `ImportModal`, `CreateTab`, `ImportTab`, and `GenerateTab`.
|
||||
|
||||
### Sharing
|
||||
|
||||
- `ShareLink` may target a `DECK`, `QUIZ`, or `GROUP`.
|
||||
- Reuse `src/components/ui/ShareMenu.tsx`.
|
||||
- Public routes are `/shared/<classSlug>/<type>/<token>`.
|
||||
- Public viewers are read-only.
|
||||
- Public viewers must not write authenticated progress.
|
||||
- Validate that token, type, and class slug agree before returning shared content.
|
||||
- A deck or quiz may appear publicly shared through its group. Preserve inherited group-share behavior and `?itemId=...` handling.
|
||||
|
||||
## Verification checklist
|
||||
|
||||
Before editing:
|
||||
|
||||
- Read `git status --short`.
|
||||
- Inspect the target component, route, service, and schema paths relevant to the requested behavior.
|
||||
- Check whether the change affects authentication, public sharing, migrations, generated Prisma output, or real study data.
|
||||
|
||||
During implementation:
|
||||
|
||||
- Keep route, service, utility, and component responsibilities clear.
|
||||
- Add validation for new external input.
|
||||
- Reuse existing design tokens, components, and services.
|
||||
- Consider loading, error, empty, mobile, keyboard, light-theme, and dark-theme states.
|
||||
- Avoid unrelated cleanup.
|
||||
|
||||
After editing:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
If lint already fails, report baseline failures separately.
|
||||
|
||||
For UI work, manually verify:
|
||||
|
||||
1. The affected authenticated route
|
||||
2. The corresponding public/shared route when applicable
|
||||
3. Loading, error, and empty states
|
||||
4. Study resume and restart behavior when relevant
|
||||
5. Drag and drop when relevant
|
||||
6. Light and dark themes
|
||||
7. A narrow mobile viewport
|
||||
8. No horizontal overflow
|
||||
9. Keyboard access for changed controls
|
||||
|
||||
## Known repository conditions
|
||||
|
||||
- `README.md` is still mostly stock create-next-app documentation.
|
||||
- `study-app-implementation-plan.md` contains useful historical context but may be stale.
|
||||
- `task.md` records completed implementation phases.
|
||||
- `SKILL.md` describes the expected workflow for new API routes.
|
||||
- The current lint baseline includes existing errors such as `any` usage and React effect-rule violations.
|
||||
- A successful `next build` is the primary repository-wide compile and type check.
|
||||
- The current middleware convention produces a known deprecation warning.
|
||||
- `docker-compose.yml` may publish port `3000`, while the production Docker image uses port `3726`. Verify the mapping before relying on production Compose.
|
||||
- Do not silently fix known baseline issues as unrelated cleanup.
|
||||
|
||||
## Extending the application
|
||||
|
||||
For a genuinely new study mode:
|
||||
|
||||
1. Add or update the Prisma model and migration when persistence is required.
|
||||
2. Add a focused service in `src/services/`.
|
||||
3. Add API routes under `src/app/api/`.
|
||||
4. Add page routes and client components.
|
||||
5. Add the mode to `src/config/studyModes.ts` when it belongs in shared navigation.
|
||||
6. Extend progress, import, scoring, or sharing only when required.
|
||||
7. Verify authentication, public/private boundaries, mobile layout, and both themes.
|
||||
|
||||
Do not add a new mode by placing unrelated logic into `deckService.ts`, `quizService.ts`, or a large page component.
|
||||
BIN
dev.db
BIN
dev.db
Binary file not shown.
840
package-lock.json
generated
840
package-lock.json
generated
|
|
@ -22,6 +22,7 @@
|
|||
"react-dom": "19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"ts-fsrs": "^5.4.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -33,7 +34,8 @@
|
|||
"eslint-config-next": "16.2.9",
|
||||
"prisma": "^7.8.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
|
|
@ -1349,6 +1351,16 @@
|
|||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.139.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@phc/format": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
|
||||
|
|
@ -1737,6 +1749,293 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
|
||||
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
|
||||
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.11.1",
|
||||
"@emnapi/runtime": "1.11.1",
|
||||
"@napi-rs/wasm-runtime": "^1.1.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
|
|
@ -2042,6 +2341,17 @@
|
|||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
||||
|
|
@ -2051,6 +2361,13 @@
|
|||
"@types/ms": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
|
|
@ -2110,6 +2427,7 @@
|
|||
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
|
|
@ -2790,6 +3108,119 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
|
||||
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
|
||||
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.10",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
|
||||
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
|
||||
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.10",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
|
||||
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
|
||||
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
|
||||
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||
|
|
@ -3040,6 +3471,16 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-types-flow": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
||||
|
|
@ -3395,6 +3836,16 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
|
|
@ -4061,6 +4512,13 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
|
||||
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
|
|
@ -4555,6 +5013,16 @@
|
|||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esutils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
|
|
@ -4574,6 +5042,16 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/exsolve": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz",
|
||||
|
|
@ -4797,6 +5275,21 @@
|
|||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
|
|
@ -7675,6 +8168,20 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
||||
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ohash": {
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
||||
|
|
@ -8407,6 +8914,40 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.139.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-x64": "1.1.5",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.5",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.5",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.5",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.5",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.5",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
|
|
@ -8739,6 +9280,13 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
|
|
@ -8833,6 +9381,13 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
|
|
@ -9130,6 +9685,23 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
|
|
@ -9179,6 +9751,16 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
|
@ -9225,6 +9807,15 @@
|
|||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-fsrs": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/ts-fsrs/-/ts-fsrs-5.4.1.tgz",
|
||||
"integrity": "sha512-mOp9+oexJexBTkwjg/jQI1aSUQRLIAvbimeKHLSmVdNJPwObugFNKmZkoggH5d6kZ0uaWLboP1Al1DnXAfIb9w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsconfig-paths": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
||||
|
|
@ -9646,6 +10237,236 @@
|
|||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
|
||||
"integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.16",
|
||||
"rolldown": "~1.1.4",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.3.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
"sass": "^1.70.0",
|
||||
"sass-embedded": "^1.70.0",
|
||||
"stylus": ">=0.54.8",
|
||||
"sugarss": "^5.0.0",
|
||||
"terser": "^5.16.0",
|
||||
"tsx": "^4.8.1",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitejs/devtools": {
|
||||
"optional": true
|
||||
},
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
},
|
||||
"jiti": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
"sass-embedded": {
|
||||
"optional": true
|
||||
},
|
||||
"stylus": {
|
||||
"optional": true
|
||||
},
|
||||
"sugarss": {
|
||||
"optional": true
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
},
|
||||
"tsx": {
|
||||
"optional": true
|
||||
},
|
||||
"yaml": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/postcss": {
|
||||
"version": "8.5.19",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
|
||||
"integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
|
||||
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.10",
|
||||
"@vitest/mocker": "4.1.10",
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/runner": "4.1.10",
|
||||
"@vitest/snapshot": "4.1.10",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.10",
|
||||
"@vitest/browser-preview": "4.1.10",
|
||||
"@vitest/browser-webdriverio": "4.1.10",
|
||||
"@vitest/coverage-istanbul": "4.1.10",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"@vitest/ui": "4.1.10",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/std-env": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
|
||||
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
|
@ -9750,6 +10571,23 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
@ -23,6 +24,7 @@
|
|||
"react-dom": "19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"ts-fsrs": "^5.4.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -34,6 +36,7 @@
|
|||
"eslint-config-next": "16.2.9",
|
||||
"prisma": "^7.8.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "StudyActivity" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"type" TEXT NOT NULL,
|
||||
"occurredAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "StudyActivity_occurredAt_idx" ON "StudyActivity"("occurredAt");
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "SpacedRepetitionSet" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"newCardsPerDay" INTEGER NOT NULL DEFAULT 30,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "SpacedRepetitionSet_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SpacedRepetitionSetDeck" (
|
||||
"setId" TEXT NOT NULL,
|
||||
"deckId" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"addedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
PRIMARY KEY ("setId", "deckId"),
|
||||
CONSTRAINT "SpacedRepetitionSetDeck_setId_fkey" FOREIGN KEY ("setId") REFERENCES "SpacedRepetitionSet" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "SpacedRepetitionSetDeck_deckId_fkey" FOREIGN KEY ("deckId") REFERENCES "Deck" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SpacedRepetitionCardState" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"setId" TEXT NOT NULL,
|
||||
"flashcardId" TEXT NOT NULL,
|
||||
"due" DATETIME NOT NULL,
|
||||
"stability" REAL NOT NULL,
|
||||
"difficulty" REAL NOT NULL,
|
||||
"elapsedDays" INTEGER NOT NULL,
|
||||
"scheduledDays" INTEGER NOT NULL,
|
||||
"learningSteps" INTEGER NOT NULL DEFAULT 0,
|
||||
"reps" INTEGER NOT NULL,
|
||||
"lapses" INTEGER NOT NULL,
|
||||
"state" INTEGER NOT NULL,
|
||||
"firstReviewedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastReview" DATETIME,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "SpacedRepetitionCardState_setId_fkey" FOREIGN KEY ("setId") REFERENCES "SpacedRepetitionSet" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "SpacedRepetitionCardState_flashcardId_fkey" FOREIGN KEY ("flashcardId") REFERENCES "Flashcard" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SpacedRepetitionSet_classId_sortOrder_idx" ON "SpacedRepetitionSet"("classId", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SpacedRepetitionSetDeck_deckId_idx" ON "SpacedRepetitionSetDeck"("deckId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SpacedRepetitionCardState_setId_due_idx" ON "SpacedRepetitionCardState"("setId", "due");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SpacedRepetitionCardState_setId_firstReviewedAt_idx" ON "SpacedRepetitionCardState"("setId", "firstReviewedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SpacedRepetitionCardState_flashcardId_idx" ON "SpacedRepetitionCardState"("flashcardId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SpacedRepetitionCardState_setId_flashcardId_key" ON "SpacedRepetitionCardState"("setId", "flashcardId");
|
||||
40
prisma/migrations/20260714010000_add_arcade/migration.sql
Normal file
40
prisma/migrations/20260714010000_add_arcade/migration.sql
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "ArcadePack" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"gameType" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"schemaVersion" INTEGER NOT NULL,
|
||||
"sourceJson" TEXT NOT NULL,
|
||||
"normalizedJson" TEXT NOT NULL,
|
||||
"validationReportJson" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "ArcadePack_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ArcadeAttempt" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"arcadePackId" TEXT NOT NULL,
|
||||
"mode" TEXT NOT NULL,
|
||||
"score" INTEGER NOT NULL,
|
||||
"maxScore" INTEGER NOT NULL,
|
||||
"accuracy" REAL NOT NULL,
|
||||
"durationSeconds" INTEGER NOT NULL,
|
||||
"mistakes" INTEGER NOT NULL,
|
||||
"hintsUsed" INTEGER NOT NULL,
|
||||
"settingsJson" TEXT NOT NULL,
|
||||
"resultsJson" TEXT NOT NULL,
|
||||
"seed" TEXT NOT NULL,
|
||||
"completedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "ArcadeAttempt_arcadePackId_fkey" FOREIGN KEY ("arcadePackId") REFERENCES "ArcadePack" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ArcadePack_classId_gameType_sortOrder_idx" ON "ArcadePack"("classId", "gameType", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ArcadeAttempt_arcadePackId_completedAt_idx" ON "ArcadeAttempt"("arcadePackId", "completedAt");
|
||||
|
|
@ -14,9 +14,11 @@ model Class {
|
|||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
decks Deck[]
|
||||
quizSets QuizSet[]
|
||||
materialGroups MaterialGroup[]
|
||||
decks Deck[]
|
||||
quizSets QuizSet[]
|
||||
materialGroups MaterialGroup[]
|
||||
spacedRepetitionSets SpacedRepetitionSet[]
|
||||
arcadePacks ArcadePack[]
|
||||
}
|
||||
|
||||
model Deck {
|
||||
|
|
@ -27,12 +29,13 @@ model Deck {
|
|||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
groupId String?
|
||||
group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
|
||||
cards Flashcard[]
|
||||
shareLink ShareLink?
|
||||
progress StudyProgress[]
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
groupId String?
|
||||
group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
|
||||
cards Flashcard[]
|
||||
shareLink ShareLink?
|
||||
progress StudyProgress[]
|
||||
spacedRepetitionMemberships SpacedRepetitionSetDeck[]
|
||||
}
|
||||
|
||||
model Flashcard {
|
||||
|
|
@ -42,7 +45,8 @@ model Flashcard {
|
|||
back String
|
||||
sortOrder Int @default(0)
|
||||
|
||||
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
|
||||
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
|
||||
spacedRepetitionStates SpacedRepetitionCardState[]
|
||||
}
|
||||
|
||||
model QuizSet {
|
||||
|
|
@ -53,9 +57,9 @@ model QuizSet {
|
|||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
groupId String?
|
||||
group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
|
||||
group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
|
||||
questions Question[]
|
||||
attempts QuizAttempt[]
|
||||
shareLink ShareLink?
|
||||
|
|
@ -141,16 +145,121 @@ model Setting {
|
|||
value String
|
||||
}
|
||||
|
||||
model StudyActivity {
|
||||
id String @id @default(uuid())
|
||||
type String // "FLASHCARD" | "QUIZ_QUESTION" | "ARCADE_GROUP"
|
||||
occurredAt DateTime @default(now())
|
||||
|
||||
@@index([occurredAt])
|
||||
}
|
||||
|
||||
model ArcadePack {
|
||||
id String @id @default(uuid())
|
||||
classId String
|
||||
gameType String
|
||||
name String
|
||||
description String?
|
||||
schemaVersion Int
|
||||
sourceJson String
|
||||
normalizedJson String
|
||||
validationReportJson String
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
attempts ArcadeAttempt[]
|
||||
|
||||
@@index([classId, gameType, sortOrder])
|
||||
}
|
||||
|
||||
model ArcadeAttempt {
|
||||
id String @id @default(uuid())
|
||||
arcadePackId String
|
||||
mode String
|
||||
score Int
|
||||
maxScore Int
|
||||
accuracy Float
|
||||
durationSeconds Int
|
||||
mistakes Int
|
||||
hintsUsed Int
|
||||
settingsJson String
|
||||
resultsJson String
|
||||
seed String
|
||||
completedAt DateTime @default(now())
|
||||
|
||||
arcadePack ArcadePack @relation(fields: [arcadePackId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([arcadePackId, completedAt])
|
||||
}
|
||||
|
||||
model MaterialGroup {
|
||||
id String @id @default(uuid())
|
||||
classId String
|
||||
name String
|
||||
type String // "DECK" | "QUIZ"
|
||||
type String // "DECK" | "QUIZ"
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
decks Deck[]
|
||||
quizSets QuizSet[]
|
||||
shareLink ShareLink?
|
||||
}
|
||||
|
||||
model SpacedRepetitionSet {
|
||||
id String @id @default(uuid())
|
||||
classId String
|
||||
name String
|
||||
description String?
|
||||
newCardsPerDay Int @default(30)
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
decks SpacedRepetitionSetDeck[]
|
||||
cardStates SpacedRepetitionCardState[]
|
||||
|
||||
@@index([classId, sortOrder])
|
||||
}
|
||||
|
||||
model SpacedRepetitionSetDeck {
|
||||
setId String
|
||||
deckId String
|
||||
sortOrder Int @default(0)
|
||||
addedAt DateTime @default(now())
|
||||
|
||||
set SpacedRepetitionSet @relation(fields: [setId], references: [id], onDelete: Cascade)
|
||||
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([setId, deckId])
|
||||
@@index([deckId])
|
||||
}
|
||||
|
||||
model SpacedRepetitionCardState {
|
||||
id String @id @default(uuid())
|
||||
setId String
|
||||
flashcardId String
|
||||
due DateTime
|
||||
stability Float
|
||||
difficulty Float
|
||||
elapsedDays Int
|
||||
scheduledDays Int
|
||||
learningSteps Int @default(0)
|
||||
reps Int
|
||||
lapses Int
|
||||
state Int
|
||||
firstReviewedAt DateTime @default(now())
|
||||
lastReview DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
set SpacedRepetitionSet @relation(fields: [setId], references: [id], onDelete: Cascade)
|
||||
flashcard Flashcard @relation(fields: [flashcardId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([setId, flashcardId])
|
||||
@@index([setId, due])
|
||||
@@index([setId, firstReviewedAt])
|
||||
@@index([flashcardId])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ARCADE_RENDERERS } from "@/components/arcade/rendererRegistry";
|
||||
import { getArcadePack } from "@/services/arcadeService";
|
||||
|
||||
export default async function ConnectionsPlayPage(
|
||||
props: PageProps<"/[classSlug]/arcade/connections/[packId]/play">
|
||||
) {
|
||||
const [{ classSlug, packId }, query] = await Promise.all([props.params, props.searchParams]);
|
||||
const pack = await getArcadePack(packId);
|
||||
if (!pack || pack.class.slug !== classSlug || pack.gameType !== "connections" || pack.normalized.type !== "connections") notFound();
|
||||
const normalized = pack.normalized;
|
||||
const mistakesValue = Number(query.mistakes);
|
||||
const allowedMistakes = Number.isInteger(mistakesValue) && mistakesValue >= 1 && mistakesValue <= 8
|
||||
? mistakesValue
|
||||
: normalized.settings.allowedMistakes;
|
||||
const Renderer = ARCADE_RENDERERS.connections;
|
||||
return <Renderer seed={randomUUID()} packId={pack.id} packName={pack.name} classSlug={classSlug} pack={normalized} settings={{ allowedMistakes, oneAwayFeedback: query.oneAway !== "0" }} />;
|
||||
}
|
||||
12
src/app/(protected)/[classSlug]/arcade/connections/page.tsx
Normal file
12
src/app/(protected)/[classSlug]/arcade/connections/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { ConnectionsHub } from "@/components/arcade/ConnectionsHub";
|
||||
import { getClassBySlug } from "@/services/classService";
|
||||
import { listArcadePacks } from "@/services/arcadeService";
|
||||
|
||||
export default async function ConnectionsHubPage(props: PageProps<"/[classSlug]/arcade/connections">) {
|
||||
const { classSlug } = await props.params;
|
||||
const classItem = await getClassBySlug(classSlug);
|
||||
if (!classItem) notFound();
|
||||
const packs = await listArcadePacks(classItem.id, "connections");
|
||||
return <ConnectionsHub classId={classItem.id} classSlug={classSlug} initialPacks={packs} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ARCADE_RENDERERS } from "@/components/arcade/rendererRegistry";
|
||||
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
|
||||
import type { CrosswordSize, NormalizedCrosswordPack } from "@/types/arcade";
|
||||
import { getArcadePack } from "@/services/arcadeService";
|
||||
|
||||
const SIZES = new Set<CrosswordSize>(["mini", "standard", "large", "extra-large"]);
|
||||
|
||||
export default async function CrosswordPlayPage(props: { params: Promise<{ classSlug: string; packId: string }>; searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const [{ classSlug, packId }, query] = await Promise.all([props.params, props.searchParams]);
|
||||
const pack = await getArcadePack(packId);
|
||||
if (!pack || pack.class.slug !== classSlug || pack.gameType !== "crossword" || pack.normalized.type !== "crossword") notFound();
|
||||
const requestedSize = typeof query.size === "string" ? query.size : "standard";
|
||||
const size: CrosswordSize = SIZES.has(requestedSize as CrosswordSize) ? requestedSize as CrosswordSize : "standard";
|
||||
const seed = randomUUID();
|
||||
const normalized = pack.normalized as NormalizedCrosswordPack;
|
||||
const layout = generateCrosswordLayout(normalized, size, seed);
|
||||
const Renderer = ARCADE_RENDERERS.crossword;
|
||||
return <Renderer seed={seed} packId={pack.id} packName={pack.name} classSlug={classSlug} pack={normalized} layout={layout} settings={{ size, instantCheck: query.instant === "1", allowHints: query.hints !== "0" }} />;
|
||||
}
|
||||
12
src/app/(protected)/[classSlug]/arcade/crossword/page.tsx
Normal file
12
src/app/(protected)/[classSlug]/arcade/crossword/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { CrosswordHub } from "@/components/arcade/CrosswordHub";
|
||||
import { getClassBySlug } from "@/services/classService";
|
||||
import { listArcadePacks } from "@/services/arcadeService";
|
||||
|
||||
export default async function CrosswordHubPage(props: { params: Promise<{ classSlug: string }> }) {
|
||||
const { classSlug } = await props.params;
|
||||
const classItem = await getClassBySlug(classSlug);
|
||||
if (!classItem) notFound();
|
||||
const packs = await listArcadePacks(classItem.id, "crossword");
|
||||
return <CrosswordHub classId={classItem.id} classSlug={classSlug} initialPacks={packs} />;
|
||||
}
|
||||
3
src/app/(protected)/[classSlug]/arcade/layout.tsx
Normal file
3
src/app/(protected)/[classSlug]/arcade/layout.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function ArcadeLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="arcade-route">{children}</div>;
|
||||
}
|
||||
46
src/app/(protected)/[classSlug]/arcade/page.tsx
Normal file
46
src/app/(protected)/[classSlug]/arcade/page.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import Link from "next/link";
|
||||
import { ARCADE_GAMES } from "@/config/arcadeGames";
|
||||
|
||||
function ConnectionsVisual() {
|
||||
return <div className="arcade-visual connections-visual" aria-hidden><div className="connections-visual-grid">{Array.from({ length: 16 }, (_, index) => <i key={index} />)}</div><span className="connections-visual-label">4 hidden groups</span></div>;
|
||||
}
|
||||
|
||||
function CrosswordVisual() {
|
||||
const cells = ["C", "", "", "", "R", "", "O", "", "S", "T", "U", "D", "Y", "", "S", "", "", "", "W", "", "", "", "O", "", "", "", "R", "", "", "", "D", ""];
|
||||
return <div className="arcade-visual crossword-visual" aria-hidden><div className="crossword-grid">{cells.map((letter, index) => <i key={index} className={letter ? "has-letter" : ""}>{letter}</i>)}</div><span className="crossword-pencil">✎</span></div>;
|
||||
}
|
||||
|
||||
function FallingBlocksVisual() {
|
||||
return <div className="arcade-visual blocks-visual" aria-hidden><span className="blocks-arrow">↓</span><div className="blocks-piece blocks-piece-a"><i /><i /><i /><i /></div><div className="blocks-piece blocks-piece-b"><i /><i /><i /></div><div className="blocks-floor">{Array.from({ length: 9 }, (_, index) => <i key={index} />)}</div></div>;
|
||||
}
|
||||
|
||||
function AsteroidVisual() {
|
||||
return <div className="arcade-visual asteroid-visual" aria-hidden><i className="arcade-star star-a" /><i className="arcade-star star-b" /><i className="arcade-star star-c" /><span className="asteroid asteroid-a" /><span className="asteroid asteroid-b" /><span className="asteroid asteroid-c" /><span className="space-station"><i /></span><span className="laser-beam" /><span className="shield-ring" /></div>;
|
||||
}
|
||||
|
||||
function GameVisual({ gameKey }: { gameKey: (typeof ARCADE_GAMES)[number]["key"] }) {
|
||||
if (gameKey === "connections") return <ConnectionsVisual />;
|
||||
if (gameKey === "crossword") return <CrosswordVisual />;
|
||||
if (gameKey === "falling-blocks") return <FallingBlocksVisual />;
|
||||
return <AsteroidVisual />;
|
||||
}
|
||||
|
||||
export default async function ArcadePage(props: PageProps<"/[classSlug]/arcade">) {
|
||||
const { classSlug } = await props.params;
|
||||
return (
|
||||
<div className="arcade-lobby pb-20">
|
||||
<div className="arcade-lobby-heading mb-8">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.22em] text-primary">Insert curiosity</p>
|
||||
<h2 className="mt-2 text-4xl font-black tracking-[-0.05em] text-text-heading sm:text-5xl">Choose your cabinet</h2>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-text-secondary">Step away from the desk and turn your study material into a quick challenge.</p>
|
||||
</div>
|
||||
|
||||
<div className="arcade-cabinet-grid grid gap-5 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{ARCADE_GAMES.map((game) => {
|
||||
const card = <article className={`arcade-cabinet arcade-cabinet-${game.key} ${game.available ? "is-playable" : "is-coming"}`}><GameVisual gameKey={game.key} /><div className="arcade-cabinet-copy"><p className="arcade-cabinet-status">{game.estimatedMinutes}</p><h3>{game.name}</h3><p>{game.description}</p><span className="arcade-cabinet-action">{game.available ? `Play ${game.name} →` : "Coming soon"}</span></div></article>;
|
||||
return game.available ? <Link key={game.key} href={`/${classSlug}/arcade/${game.path}`} className="rounded-[1.75rem] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary">{card}</Link> : <div key={game.key}>{card}</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -90,9 +90,9 @@ export default function DeckStudyPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full py-2 md:py-4">
|
||||
<div className="mx-auto w-full max-w-5xl py-1 md:py-3">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 mb-6">
|
||||
<div className="mb-7 flex flex-col items-start justify-between gap-4 rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5 md:flex-row md:items-center">
|
||||
<div>
|
||||
<button
|
||||
onClick={() => router.push(`/${classSlug}/flashcards`)}
|
||||
|
|
@ -103,7 +103,8 @@ export default function DeckStudyPage() {
|
|||
</svg>
|
||||
Back to decks
|
||||
</button>
|
||||
<h1 className="text-xl font-bold text-text-heading">{deck.name}</h1>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Flashcard session</p>
|
||||
<h1 className="editorial-title mt-1 text-3xl text-text-heading">{deck.name}</h1>
|
||||
{deck.description && (
|
||||
<p className="text-sm text-text-secondary mt-1">{deck.description}</p>
|
||||
)}
|
||||
|
|
@ -126,7 +127,7 @@ export default function DeckStudyPage() {
|
|||
<div className="flex items-center gap-2 md:gap-4 ml-auto">
|
||||
<ShareMenu targetType="DECK" contentId={deck.id} classSlug={classSlug} />
|
||||
|
||||
<div className="flex bg-bg-surface-alt rounded-lg p-0.5 border border-border-light">
|
||||
<div className="flex rounded-xl border border-border-light bg-bg-surface-alt p-1">
|
||||
<button
|
||||
onClick={() => setView("study")}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { ReorderItem } from "@/types/study";
|
||||
|
||||
interface DeckItem {
|
||||
id: string;
|
||||
|
|
@ -65,7 +66,14 @@ function getProgressLabel(deck: DeckItem) {
|
|||
return `${current}/${total}, ${modeLabel} · ${correctCount} ✓`;
|
||||
}
|
||||
|
||||
function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
|
||||
interface SortableDeckCardProps {
|
||||
deck: DeckItem;
|
||||
onEdit: (deck: DeckItem) => void;
|
||||
onDelete: (deck: DeckItem) => void;
|
||||
classSlug: string;
|
||||
}
|
||||
|
||||
function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCardProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: deck.id });
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
|
|
@ -76,8 +84,8 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
|
|||
const progressLabel = getProgressLabel(deck);
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="group flex flex-col bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300 h-full">
|
||||
<div className="p-4 flex flex-col flex-1 h-full">
|
||||
<div ref={setNodeRef} style={style} className="group flex h-full flex-col rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)] transition-all duration-300 hover:-translate-y-0.5 hover:border-primary/25 hover:shadow-[var(--shadow-card-hover)]">
|
||||
<div className="flex h-full flex-1 flex-col p-5">
|
||||
<div className="flex items-start gap-2">
|
||||
<div {...attributes} {...listeners} className="mt-1 cursor-grab active:cursor-grabbing text-text-muted hover:text-text-heading">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -85,7 +93,7 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
|
|||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-text-heading mb-1">{deck.name}</h3>
|
||||
<h3 className="mb-1 text-lg font-extrabold text-text-heading">{deck.name}</h3>
|
||||
{deck.description && <p className="text-sm text-text-secondary mb-3 line-clamp-2">{deck.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -99,9 +107,10 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-4 border-t border-border-light pl-7">
|
||||
{deck.progress?.length ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 border-t border-border-light pt-4 pl-7 sm:flex-row sm:items-center">
|
||||
<div className="flex w-full min-w-0 gap-2 sm:flex-1">
|
||||
{deck.progress?.length ? (
|
||||
<>
|
||||
<Link href={`/${classSlug}/flashcards/${deck.id}`} className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200">
|
||||
Continue
|
||||
</Link>
|
||||
|
|
@ -119,22 +128,26 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
|
|||
>
|
||||
Restart
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Link href={`/${classSlug}/flashcards/${deck.id}`} className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200">
|
||||
Study
|
||||
</Link>
|
||||
)}
|
||||
<button onClick={() => onEdit(deck)} className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer" title="Edit">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => onDelete(deck)} className="p-2 rounded-lg text-text-muted hover:text-error hover:bg-error-bg transition-all duration-200 cursor-pointer" title="Delete">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Link href={`/${classSlug}/flashcards/${deck.id}`} className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200">
|
||||
Study
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button onClick={() => onEdit(deck)} className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer" title="Edit">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => onDelete(deck)} className="p-2 rounded-lg text-text-muted hover:text-error hover:bg-error-bg transition-all duration-200 cursor-pointer" title="Delete">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<ShareMenu targetType="DECK" contentId={deck.id} classSlug={classSlug} compact />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -309,8 +322,6 @@ export default function FlashcardsPage() {
|
|||
if (!activeDeck) return;
|
||||
|
||||
let targetGroupId: string | null = null;
|
||||
let targetIndex = 0;
|
||||
|
||||
const overContainerId = over.data.current?.sortable?.containerId;
|
||||
if (overContainerId) {
|
||||
targetGroupId = overContainerId === "uncategorized" ? null : overContainerId;
|
||||
|
|
@ -339,7 +350,7 @@ export default function FlashcardsPage() {
|
|||
}
|
||||
|
||||
const affectedGroups = new Set([activeDeck.groupId, targetGroupId]);
|
||||
const updates: any[] = [];
|
||||
const updates: ReorderItem[] = [];
|
||||
|
||||
affectedGroups.forEach(gId => {
|
||||
const gItems = newItems.filter(d => d.groupId === gId);
|
||||
|
|
@ -369,15 +380,15 @@ export default function FlashcardsPage() {
|
|||
const activeDeck = decks.find(d => d.id === activeId);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||
<div className="pb-20">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-text-heading">Flashcard Decks</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => setIsCreatingGroup(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-bg-surface-alt text-text-heading font-medium border border-border-light hover:bg-border transition-all duration-200 shadow-sm cursor-pointer">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Collection</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Flashcard decks</h2></div>
|
||||
<div className="grid grid-cols-2 gap-3 sm:flex">
|
||||
<button onClick={() => setIsCreatingGroup(true)} className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl border border-border-light bg-bg-surface px-4 font-bold text-text-heading shadow-sm transition-colors hover:bg-bg-surface-alt">
|
||||
Add Group
|
||||
</button>
|
||||
<button onClick={() => setShowImport(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 shadow-sm cursor-pointer">
|
||||
<button onClick={() => setShowImport(true)} className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl bg-primary px-4 font-bold text-white shadow-sm transition-colors hover:bg-primary-hover">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
|
|
@ -387,7 +398,7 @@ export default function FlashcardsPage() {
|
|||
</div>
|
||||
|
||||
{isCreatingGroup && (
|
||||
<div className="mb-6 p-4 bg-bg-surface rounded-xl border border-primary/20 shadow-sm flex items-center gap-3">
|
||||
<div className="animate-slide-up mb-6 flex flex-col gap-3 rounded-2xl border border-primary/20 bg-bg-surface p-5 shadow-sm sm:flex-row sm:items-center">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
|
|
@ -407,8 +418,8 @@ export default function FlashcardsPage() {
|
|||
|
||||
{editingId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-bg-surface rounded-xl p-6 w-full max-w-md shadow-lg border border-border">
|
||||
<h3 className="text-lg font-semibold text-text-heading mb-4">Edit Deck</h3>
|
||||
<div className="w-full max-w-md rounded-3xl border border-border bg-bg-surface p-6 shadow-[var(--shadow-modal)]">
|
||||
<h3 className="editorial-title mb-4 text-2xl text-text-heading">Edit deck</h3>
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
|
|
@ -443,7 +454,7 @@ export default function FlashcardsPage() {
|
|||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
|
||||
<div className="space-y-8">
|
||||
{groupedDecks.map(group => (
|
||||
<div key={group.id} className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
|
||||
<div key={group.id} className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
|
||||
<div className={`flex items-center justify-between ${collapsedGroups[group.id] ? "" : "mb-4"}`}>
|
||||
{editingGroupId === group.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -456,7 +467,7 @@ export default function FlashcardsPage() {
|
|||
<button onClick={() => toggleGroup(group.id)} className="p-1 rounded text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer" title={collapsedGroups[group.id] ? "Expand" : "Collapse"}>
|
||||
<svg className={`w-5 h-5 transition-transform duration-200 ${collapsedGroups[group.id] ? '-rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||
</button>
|
||||
<h3 className="text-lg font-semibold text-text-heading">{group.name}</h3>
|
||||
<h3 className="text-lg font-extrabold text-text-heading">{group.name}</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -484,7 +495,7 @@ export default function FlashcardsPage() {
|
|||
))}
|
||||
|
||||
{/* Uncategorized */}
|
||||
<div className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
|
||||
<div className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
|
||||
<div className={`flex items-center gap-2 ${collapsedGroups["uncategorized"] ? "" : "mb-4"}`}>
|
||||
<button onClick={() => toggleGroup("uncategorized")} className="p-1 rounded text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer" title={collapsedGroups["uncategorized"] ? "Expand" : "Collapse"}>
|
||||
<svg className={`w-5 h-5 transition-transform duration-200 ${collapsedGroups["uncategorized"] ? '-rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ export default async function ClassLayout(props: LayoutProps<"/[classSlug]">) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Dynamic Header & Tabs */}
|
||||
<ClassHeader classSlug={classSlug} className={classData.name} />
|
||||
<div className="app-page">
|
||||
{/* Dynamic class header */}
|
||||
<ClassHeader className={classData.name} />
|
||||
|
||||
{/* Page content */}
|
||||
{props.children}
|
||||
|
|
|
|||
|
|
@ -124,9 +124,9 @@ export default function QuizStudyPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full py-2 md:py-4">
|
||||
<div className="mx-auto w-full max-w-5xl py-1 md:py-3">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row items-start justify-between gap-4 mb-6">
|
||||
<div className="mb-7 flex flex-col items-start justify-between gap-4 rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5 md:flex-row">
|
||||
<div>
|
||||
<button
|
||||
onClick={() => router.push(`/${classSlug}/quizzes`)}
|
||||
|
|
@ -142,7 +142,7 @@ export default function QuizStudyPage() {
|
|||
onClick={() => setShowTopics(!showTopics)}
|
||||
className="flex items-center gap-2 group cursor-pointer text-left focus:outline-none mb-2"
|
||||
>
|
||||
<h1 className="text-3xl font-bold font-serif text-text-heading group-hover:text-primary transition-colors">
|
||||
<h1 className="editorial-title text-3xl text-text-heading transition-colors group-hover:text-primary sm:text-4xl">
|
||||
{quiz.name}
|
||||
</h1>
|
||||
<svg
|
||||
|
|
@ -180,7 +180,7 @@ export default function QuizStudyPage() {
|
|||
<ShareMenu targetType="QUIZ" contentId={quiz.id} classSlug={classSlug} />
|
||||
|
||||
{attempts.length > 0 && (
|
||||
<div className="flex bg-bg-surface-alt rounded-lg p-0.5 border border-border-light">
|
||||
<div className="flex rounded-xl border border-border-light bg-bg-surface-alt p-1">
|
||||
<button
|
||||
onClick={() => setView("history")}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { ReorderItem } from "@/types/study";
|
||||
|
||||
interface QuizItem {
|
||||
id: string;
|
||||
|
|
@ -35,6 +36,12 @@ interface QuizItem {
|
|||
groupId: string | null;
|
||||
sortOrder: number;
|
||||
_count: { questions: number; attempts: number };
|
||||
progress: Array<{
|
||||
mode: string;
|
||||
currentIndex: number;
|
||||
orderJson: string;
|
||||
answersJson: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface MaterialGroup {
|
||||
|
|
@ -45,7 +52,14 @@ interface MaterialGroup {
|
|||
|
||||
const cache: Record<string, { quizzes: QuizItem[]; groups: MaterialGroup[] }> = {};
|
||||
|
||||
function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) {
|
||||
interface SortableQuizCardProps {
|
||||
quiz: QuizItem;
|
||||
onEdit: (quiz: QuizItem) => void;
|
||||
onDelete: (quiz: QuizItem) => void;
|
||||
classSlug: string;
|
||||
}
|
||||
|
||||
function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCardProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: quiz.id });
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
|
|
@ -54,8 +68,8 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) {
|
|||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="group bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300">
|
||||
<div className="p-4 flex flex-col flex-1 h-full">
|
||||
<div ref={setNodeRef} style={style} className="group rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)] transition-all duration-300 hover:-translate-y-0.5 hover:border-primary/25 hover:shadow-[var(--shadow-card-hover)]">
|
||||
<div className="flex h-full flex-1 flex-col p-5">
|
||||
<div className="flex items-start gap-2">
|
||||
<div {...attributes} {...listeners} className="mt-1 cursor-grab active:cursor-grabbing text-text-muted hover:text-text-heading">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -63,7 +77,7 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) {
|
|||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-text-heading mb-1">{quiz.name}</h3>
|
||||
<h3 className="mb-1 text-lg font-extrabold text-text-heading">{quiz.name}</h3>
|
||||
{quiz.description && <p className="text-sm text-text-secondary mb-3 line-clamp-2">{quiz.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -79,20 +93,48 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-border-light pl-7">
|
||||
<Link href={`/${classSlug}/quizzes/${quiz.id}`} className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200">
|
||||
Start Quiz
|
||||
</Link>
|
||||
<button onClick={() => onEdit(quiz)} className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer" title="Edit">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => onDelete(quiz)} className="p-2 rounded-lg text-text-muted hover:text-error hover:bg-error-bg transition-all duration-200 cursor-pointer" title="Delete">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="mt-4 flex flex-col gap-2 border-t border-border-light pt-4 pl-7 sm:flex-row sm:items-center">
|
||||
<div className="flex w-full min-w-0 gap-2 sm:flex-1">
|
||||
{quiz.progress?.length ? (
|
||||
<>
|
||||
<Link href={`/${classSlug}/quizzes/${quiz.id}`} className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200">
|
||||
Continue
|
||||
</Link>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm("Start over from the beginning? This will clear your current progress for this quiz.")) return;
|
||||
await fetch("/api/progress", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType: "QUIZ", contentId: quiz.id, mode: "SEQUENTIAL" }),
|
||||
}).catch(() => {});
|
||||
window.location.href = `/${classSlug}/quizzes/${quiz.id}`;
|
||||
}}
|
||||
className="flex-1 cursor-pointer rounded-lg border border-border bg-bg-surface-alt px-4 py-2 text-center text-sm font-medium text-text-heading shadow-sm transition-all duration-200 hover:bg-border"
|
||||
title="Restart"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Link href={`/${classSlug}/quizzes/${quiz.id}`} className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200">
|
||||
Start Quiz
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button onClick={() => onEdit(quiz)} className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer" title="Edit">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => onDelete(quiz)} className="p-2 rounded-lg text-text-muted hover:text-error hover:bg-error-bg transition-all duration-200 cursor-pointer" title="Delete">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<ShareMenu targetType="QUIZ" contentId={quiz.id} classSlug={classSlug} compact />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -278,8 +320,6 @@ export default function QuizzesPage() {
|
|||
if (!activeQuiz) return;
|
||||
|
||||
let targetGroupId: string | null = null;
|
||||
let targetIndex = 0;
|
||||
|
||||
// Check if over a group container directly
|
||||
const overContainerId = over.data.current?.sortable?.containerId;
|
||||
if (overContainerId) {
|
||||
|
|
@ -311,7 +351,7 @@ export default function QuizzesPage() {
|
|||
|
||||
// Re-calculate sortOrder for the affected groups to persist
|
||||
const affectedGroups = new Set([activeQuiz.groupId, targetGroupId]);
|
||||
const updates: any[] = [];
|
||||
const updates: ReorderItem[] = [];
|
||||
|
||||
affectedGroups.forEach(gId => {
|
||||
const gItems = newItems.filter(q => q.groupId === gId);
|
||||
|
|
@ -343,15 +383,15 @@ export default function QuizzesPage() {
|
|||
const activeQuiz = quizzes.find(q => q.id === activeId);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||
<div className="pb-20">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-text-heading">Quizzes</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => setIsCreatingGroup(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-bg-surface-alt text-text-heading font-medium border border-border-light hover:bg-border transition-all duration-200 shadow-sm cursor-pointer">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Practice</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Practice quizzes</h2></div>
|
||||
<div className="grid grid-cols-2 gap-3 sm:flex">
|
||||
<button onClick={() => setIsCreatingGroup(true)} className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl border border-border-light bg-bg-surface px-4 font-bold text-text-heading shadow-sm transition-colors hover:bg-bg-surface-alt">
|
||||
Add Group
|
||||
</button>
|
||||
<button onClick={() => setShowImport(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 shadow-sm cursor-pointer">
|
||||
<button onClick={() => setShowImport(true)} className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl bg-primary px-4 font-bold text-white shadow-sm transition-colors hover:bg-primary-hover">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
|
|
@ -361,7 +401,7 @@ export default function QuizzesPage() {
|
|||
</div>
|
||||
|
||||
{isCreatingGroup && (
|
||||
<div className="mb-6 p-4 bg-bg-surface rounded-xl border border-primary/20 shadow-sm flex items-center gap-3">
|
||||
<div className="animate-slide-up mb-6 flex flex-col gap-3 rounded-2xl border border-primary/20 bg-bg-surface p-5 shadow-sm sm:flex-row sm:items-center">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
|
|
@ -382,8 +422,8 @@ export default function QuizzesPage() {
|
|||
{/* Editing quiz modal/inline - simplified as a modal-like overlay for simplicity when editing */}
|
||||
{editingId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-bg-surface rounded-xl p-6 w-full max-w-md shadow-lg border border-border">
|
||||
<h3 className="text-lg font-semibold text-text-heading mb-4">Edit Quiz</h3>
|
||||
<div className="w-full max-w-md rounded-3xl border border-border bg-bg-surface p-6 shadow-[var(--shadow-modal)]">
|
||||
<h3 className="editorial-title mb-4 text-2xl text-text-heading">Edit quiz</h3>
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
|
|
@ -418,7 +458,7 @@ export default function QuizzesPage() {
|
|||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
|
||||
<div className="space-y-8">
|
||||
{groupedQuizzes.map(group => (
|
||||
<div key={group.id} className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
|
||||
<div key={group.id} className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
|
||||
<div className={`flex items-center justify-between ${collapsedGroups[group.id] ? "" : "mb-4"}`}>
|
||||
{editingGroupId === group.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -431,7 +471,7 @@ export default function QuizzesPage() {
|
|||
<button onClick={() => toggleGroup(group.id)} className="p-1 rounded text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer" title={collapsedGroups[group.id] ? "Expand" : "Collapse"}>
|
||||
<svg className={`w-5 h-5 transition-transform duration-200 ${collapsedGroups[group.id] ? '-rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||
</button>
|
||||
<h3 className="text-lg font-semibold text-text-heading">{group.name}</h3>
|
||||
<h3 className="text-lg font-extrabold text-text-heading">{group.name}</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -459,7 +499,7 @@ export default function QuizzesPage() {
|
|||
))}
|
||||
|
||||
{/* Uncategorized */}
|
||||
<div className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
|
||||
<div className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
|
||||
<div className={`flex items-center gap-2 ${collapsedGroups["uncategorized"] ? "" : "mb-4"}`}>
|
||||
<button onClick={() => toggleGroup("uncategorized")} className="p-1 rounded text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer" title={collapsedGroups["uncategorized"] ? "Expand" : "Collapse"}>
|
||||
<svg className={`w-5 h-5 transition-transform duration-200 ${collapsedGroups["uncategorized"] ? '-rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
import { SpacedRepetitionViewer } from "@/components/spaced-repetition/SpacedRepetitionViewer";
|
||||
|
||||
export default function SpacedRepetitionStudyPage() {
|
||||
return <SpacedRepetitionViewer />;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { SpacedRepetitionSets } from "@/components/spaced-repetition/SpacedRepetitionSets";
|
||||
|
||||
export default function SpacedRepetitionPage() {
|
||||
return <SpacedRepetitionSets />;
|
||||
}
|
||||
|
|
@ -13,9 +13,9 @@ export default async function ProtectedLayout({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<div className="min-h-screen">
|
||||
<Navbar />
|
||||
<main className="flex-1">{children}</main>
|
||||
<main className="min-h-screen md:ml-60">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ActivityBanner } from "@/components/activity/ActivityBanner";
|
||||
|
||||
interface ClassItem {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
_count: { decks: number; quizSets: number };
|
||||
spacedRepetitionDueCards: number;
|
||||
spacedRepetitionLearningCards: number;
|
||||
spacedRepetitionNewCards: number;
|
||||
spacedRepetitionReadyCards: number;
|
||||
}
|
||||
|
||||
const accents = ["#4f46e5", "#f97360", "#25845f", "#c27718", "#8b5cf6", "#0284c7"];
|
||||
|
||||
export default function HomePage() {
|
||||
const [classes, setClasses] = useState<ClassItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -20,256 +27,112 @@ export default function HomePage() {
|
|||
const [editName, setEditName] = useState("");
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClasses();
|
||||
}, []);
|
||||
useEffect(() => { fetchClasses(); }, []);
|
||||
|
||||
async function fetchClasses() {
|
||||
try {
|
||||
const res = await fetch("/api/classes");
|
||||
const data = await res.json();
|
||||
setClasses(data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setClasses(await res.json());
|
||||
} finally { setLoading(false); }
|
||||
}
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newName.trim()) return;
|
||||
setCreating(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/classes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newName.trim() }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setNewName("");
|
||||
setShowCreate(false);
|
||||
fetchClasses();
|
||||
}
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
const res = await fetch("/api/classes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newName.trim() }) });
|
||||
if (res.ok) { setNewName(""); setShowCreate(false); fetchClasses(); }
|
||||
} finally { setCreating(false); }
|
||||
}
|
||||
|
||||
async function handleDelete(id: string, name: string) {
|
||||
if (!confirm(`Delete "${name}" and all its decks and quizzes?`)) return;
|
||||
|
||||
await fetch(`/api/classes/${id}`, { method: "DELETE" });
|
||||
fetchClasses();
|
||||
}
|
||||
|
||||
async function handleSaveEdit(id: string) {
|
||||
if (!editName.trim()) {
|
||||
setEditingId(null);
|
||||
return;
|
||||
}
|
||||
if (!editName.trim()) { setEditingId(null); return; }
|
||||
setSavingEdit(true);
|
||||
try {
|
||||
const res = await fetch(`/api/classes/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: editName.trim() }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEditingId(null);
|
||||
fetchClasses();
|
||||
}
|
||||
} finally {
|
||||
setSavingEdit(false);
|
||||
}
|
||||
const res = await fetch(`/api/classes/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: editName.trim() }) });
|
||||
if (res.ok) { setEditingId(null); fetchClasses(); }
|
||||
} finally { setSavingEdit(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="app-page">
|
||||
<ActivityBanner onNewClass={() => setShowCreate(true)} />
|
||||
|
||||
{showCreate && (
|
||||
<section className="animate-slide-up mb-8 rounded-2xl border border-primary/20 bg-bg-surface p-5 shadow-[var(--shadow-card)] sm:p-6">
|
||||
<div className="mb-4">
|
||||
<h2 className="editorial-title text-2xl text-text-heading">Create a class</h2>
|
||||
<p className="mt-1 text-sm text-text-secondary">Give this study space a short, memorable name.</p>
|
||||
</div>
|
||||
<form onSubmit={handleCreate} className="flex flex-col gap-3 sm:flex-row">
|
||||
<input value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="e.g. Pathophysiology" autoFocus className="min-h-12 flex-1 rounded-xl border border-border bg-bg-surface-alt/60 px-4 text-text-heading placeholder:text-text-muted focus:border-primary" />
|
||||
<button type="submit" disabled={creating || !newName.trim()} className="min-h-12 rounded-xl bg-primary px-6 text-sm font-bold text-white hover:bg-primary-hover disabled:opacity-45">{creating ? "Creating…" : "Create class"}</button>
|
||||
<button type="button" onClick={() => { setShowCreate(false); setNewName(""); }} className="min-h-12 rounded-xl border border-border px-5 text-sm font-bold text-text-secondary hover:bg-bg-surface-alt">Cancel</button>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="mb-5 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-heading">Your Classes</h1>
|
||||
<p className="text-text-secondary mt-1">
|
||||
Select a class to study flashcards or take quizzes
|
||||
</p>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.18em] text-primary">Study spaces</p>
|
||||
<h2 className="editorial-title mt-1 text-3xl text-text-heading">Your classes</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 shadow-sm cursor-pointer"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Class
|
||||
</button>
|
||||
<p className="hidden text-sm text-text-muted sm:block">Flashcards and quizzes, organized by class.</p>
|
||||
</div>
|
||||
|
||||
{/* Create class inline form */}
|
||||
{showCreate && (
|
||||
<div className="bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] p-5 mb-6 animate-slide-up">
|
||||
<form onSubmit={handleCreate} className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="e.g. PHA 109, Anatomy, Nursing Fundamentals..."
|
||||
autoFocus
|
||||
className="flex-1 px-3.5 py-2.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating || !newName.trim()}
|
||||
className="px-5 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover disabled:opacity-50 transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreate(false);
|
||||
setNewName("");
|
||||
}}
|
||||
className="px-4 py-2.5 rounded-lg border border-border text-text-secondary hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-bg-surface rounded-xl border border-border-light p-6 animate-subtle-pulse"
|
||||
>
|
||||
<div className="h-5 bg-bg-surface-alt rounded w-2/3 mb-3" />
|
||||
<div className="h-4 bg-bg-surface-alt rounded w-1/3" />
|
||||
</div>
|
||||
))}
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => <div key={i} className="h-52 animate-subtle-pulse rounded-2xl border border-border-light bg-bg-surface" />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && classes.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-bg-callout mb-4">
|
||||
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-text-heading mb-1">
|
||||
No classes yet
|
||||
</h2>
|
||||
<p className="text-text-secondary mb-4">
|
||||
Create your first class to start studying
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Your First Class
|
||||
</button>
|
||||
<div className="paper-grid rounded-[2rem] border border-dashed border-border bg-bg-surface px-6 py-16 text-center">
|
||||
<div className="mx-auto mb-5 grid h-14 w-14 place-items-center rounded-2xl bg-bg-callout text-2xl text-primary">+</div>
|
||||
<h2 className="editorial-title text-2xl text-text-heading">Your desk is ready</h2>
|
||||
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-text-secondary">Create your first class, then add flashcards or practice quizzes.</p>
|
||||
<button onClick={() => setShowCreate(true)} className="mt-6 min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white hover:bg-primary-hover">Create your first class</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Class grid */}
|
||||
{!loading && classes.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{classes.map((cls) => (
|
||||
<div
|
||||
key={cls.id}
|
||||
className="group relative bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300"
|
||||
>
|
||||
<Link
|
||||
href={`/${cls.slug}`}
|
||||
className="block p-6"
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{classes.map((cls, index) => (
|
||||
<article key={cls.id} className="group relative overflow-hidden rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)] transition-all hover:-translate-y-1 hover:shadow-[var(--shadow-card-hover)]">
|
||||
<div className="h-1.5" style={{ backgroundColor: accents[index % accents.length] }} />
|
||||
<Link href={`/${cls.slug}`} className="block min-h-48 p-5 sm:p-6">
|
||||
<div className="mb-8 flex items-start justify-between gap-4">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-xl bg-bg-surface-alt text-sm font-extrabold text-text-heading">{cls.name.slice(0, 2).toUpperCase()}</div>
|
||||
<span className="text-xl text-text-muted transition-transform group-hover:translate-x-1" aria-hidden>→</span>
|
||||
</div>
|
||||
{editingId === cls.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSaveEdit(cls.id);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setEditingId(null);
|
||||
}
|
||||
}}
|
||||
onBlur={() => handleSaveEdit(cls.id)}
|
||||
autoFocus
|
||||
disabled={savingEdit}
|
||||
className="w-[calc(100%-2.5rem)] text-lg font-semibold text-text-heading px-2 py-1 -ml-2 rounded bg-bg-surface-alt border border-primary/30 focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
) : (
|
||||
<h3 className="text-lg font-semibold text-text-heading group-hover:text-primary transition-colors">
|
||||
{cls.name}
|
||||
</h3>
|
||||
)}
|
||||
<div className="flex gap-4 mt-3">
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-text-secondary">
|
||||
<svg className="w-4 h-4 text-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
{cls._count.decks} {cls._count.decks === 1 ? "deck" : "decks"}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-text-secondary">
|
||||
<svg className="w-4 h-4 text-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{cls._count.quizSets} {cls._count.quizSets === 1 ? "quiz" : "quizzes"}
|
||||
</span>
|
||||
<input value={editName} onClick={(e) => { e.preventDefault(); e.stopPropagation(); }} onChange={(e) => setEditName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleSaveEdit(cls.id); } else if (e.key === "Escape") setEditingId(null); }} onBlur={() => handleSaveEdit(cls.id)} autoFocus disabled={savingEdit} className="w-full rounded-lg border border-primary bg-bg-surface-alt px-2 py-1 text-lg font-bold text-text-heading" />
|
||||
) : <h3 className="text-xl font-extrabold text-text-heading group-hover:text-primary">{cls.name}</h3>}
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs font-semibold text-text-secondary">
|
||||
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1">{cls._count.decks} {cls._count.decks === 1 ? "deck" : "decks"}</span>
|
||||
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1">{cls._count.quizSets} {cls._count.quizSets === 1 ? "quiz" : "quizzes"}</span>
|
||||
{cls.spacedRepetitionReadyCards > 0 && (
|
||||
<span
|
||||
className="rounded-full bg-error-bg px-2.5 py-1 text-error"
|
||||
title={`${cls.spacedRepetitionDueCards} due, ${cls.spacedRepetitionLearningCards} learning, ${cls.spacedRepetitionNewCards} new`}
|
||||
>
|
||||
{cls.spacedRepetitionReadyCards} {cls.spacedRepetitionReadyCards === 1 ? "card" : "cards"} ready
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="absolute top-3 right-3 flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-all duration-200">
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDelete(cls.id, cls.name);
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-text-muted hover:text-error hover:bg-error-bg transition-colors cursor-pointer"
|
||||
title="Delete class"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Edit button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setEditingId(cls.id);
|
||||
setEditName(cls.name);
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-text-muted hover:text-primary hover:bg-primary/10 transition-colors cursor-pointer"
|
||||
title="Rename class"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="absolute right-3 top-4 flex gap-1 rounded-xl border border-border-light bg-bg-surface p-1 opacity-100 shadow-sm sm:opacity-0 sm:group-hover:opacity-100">
|
||||
<button onClick={() => { setEditingId(cls.id); setEditName(cls.name); }} className="grid h-9 w-9 place-items-center rounded-lg text-text-muted hover:bg-bg-callout hover:text-primary" title="Rename class" aria-label={`Rename ${cls.name}`}>✎</button>
|
||||
<button onClick={() => handleDelete(cls.id, cls.name)} className="grid h-9 w-9 place-items-center rounded-lg text-text-muted hover:bg-error-bg hover:text-error" title="Delete class" aria-label={`Delete ${cls.name}`}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
17
src/app/api/activity/route.ts
Normal file
17
src/app/api/activity/route.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { studyActivitySchema } from "@/lib/validation/activitySchemas";
|
||||
import * as activityService from "@/services/activityService";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(await activityService.getActivitySummary());
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const parsed = studyActivitySchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid activity type" }, { status: 400 });
|
||||
}
|
||||
|
||||
await activityService.recordActivity(parsed.data.type);
|
||||
return NextResponse.json({ success: true }, { status: 201 });
|
||||
}
|
||||
19
src/app/api/arcade/import/preview/route.ts
Normal file
19
src/app/api/arcade/import/preview/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
|
||||
import { arcadePreviewRequestSchema } from "@/lib/validation/arcadeSchemas";
|
||||
import * as arcadeService from "@/services/arcadeService";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const parsed = arcadePreviewRequestSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "A game type and raw JSON are required." }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
return NextResponse.json(arcadeService.previewArcadeImport(parsed.data.gameType, parsed.data.rawJson));
|
||||
} catch (error) {
|
||||
if (error instanceof ArcadeImportError) {
|
||||
return NextResponse.json({ error: error.message, details: error.details }, { status: 400 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
42
src/app/api/arcade/packs/[id]/attempts/route.ts
Normal file
42
src/app/api/arcade/packs/[id]/attempts/route.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { arcadeAttemptCreateSchema, crosswordAttemptCreateSchema } from "@/lib/validation/arcadeSchemas";
|
||||
import * as arcadeService from "@/services/arcadeService";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
context: RouteContext<"/api/arcade/packs/[id]/attempts">
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const pack = await arcadeService.getArcadePack(id);
|
||||
if (!pack) return NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
|
||||
return NextResponse.json(await arcadeService.listArcadeAttempts(id));
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: RouteContext<"/api/arcade/packs/[id]/attempts">
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const pack = await arcadeService.getArcadePack(id);
|
||||
if (!pack) return NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = pack.gameType === "crossword"
|
||||
? crosswordAttemptCreateSchema.safeParse(body)
|
||||
: arcadeAttemptCreateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid completed attempt", details: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const attempt = pack.gameType === "crossword"
|
||||
? await arcadeService.createCrosswordAttempt(id, parsed.data as Parameters<typeof arcadeService.createCrosswordAttempt>[1])
|
||||
: await arcadeService.createArcadeAttempt(id, parsed.data as Parameters<typeof arcadeService.createArcadeAttempt>[1]);
|
||||
return attempt
|
||||
? NextResponse.json(attempt, { status: 201 })
|
||||
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Attempt could not be saved" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
23
src/app/api/arcade/packs/[id]/layout/route.ts
Normal file
23
src/app/api/arcade/packs/[id]/layout/route.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
|
||||
import type { CrosswordSize } from "@/types/arcade";
|
||||
import { getArcadePack } from "@/services/arcadeService";
|
||||
|
||||
const CROSSWORD_SIZES = new Set<CrosswordSize>(["mini", "standard", "large", "extra-large"]);
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const sizeValue = request.nextUrl.searchParams.get("size") ?? "standard";
|
||||
if (!CROSSWORD_SIZES.has(sizeValue as CrosswordSize)) {
|
||||
return NextResponse.json({ error: "A valid Crossword size is required." }, { status: 400 });
|
||||
}
|
||||
const pack = await getArcadePack(id);
|
||||
if (!pack || pack.gameType !== "crossword" || pack.normalized.type !== "crossword") {
|
||||
return NextResponse.json({ error: "Crossword pack not found" }, { status: 404 });
|
||||
}
|
||||
const size = sizeValue as CrosswordSize;
|
||||
return NextResponse.json(generateCrosswordLayout(pack.normalized, size, `crossword-hub-preview-v1:${id}:${size}`));
|
||||
}
|
||||
38
src/app/api/arcade/packs/[id]/route.ts
Normal file
38
src/app/api/arcade/packs/[id]/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { arcadePackUpdateSchema } from "@/lib/validation/arcadeSchemas";
|
||||
import * as arcadeService from "@/services/arcadeService";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
context: RouteContext<"/api/arcade/packs/[id]">
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const pack = await arcadeService.getArcadePack(id);
|
||||
return pack
|
||||
? NextResponse.json(pack)
|
||||
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
context: RouteContext<"/api/arcade/packs/[id]">
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const parsed = arcadePackUpdateSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ error: "A valid name is required" }, { status: 400 });
|
||||
const pack = await arcadeService.updateArcadePack(id, parsed.data.name);
|
||||
return pack
|
||||
? NextResponse.json(pack)
|
||||
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
context: RouteContext<"/api/arcade/packs/[id]">
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const deleted = await arcadeService.deleteArcadePack(id);
|
||||
return deleted
|
||||
? NextResponse.json({ success: true })
|
||||
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
|
||||
}
|
||||
34
src/app/api/arcade/packs/route.ts
Normal file
34
src/app/api/arcade/packs/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
|
||||
import { arcadePackCreateSchema } from "@/lib/validation/arcadeSchemas";
|
||||
import * as arcadeService from "@/services/arcadeService";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const params = new URL(request.url).searchParams;
|
||||
const classId = params.get("classId");
|
||||
const gameType = params.get("gameType");
|
||||
if (!classId || (gameType !== "connections" && gameType !== "crossword")) {
|
||||
return NextResponse.json({ error: "Valid classId and gameType values are required." }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(await arcadeService.listArcadePacks(classId, gameType));
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const parsed = arcadePackCreateSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid Arcade pack request", details: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const packs = await arcadeService.createArcadePacks(parsed.data);
|
||||
return NextResponse.json({ packs, count: packs.length }, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof ArcadeImportError) {
|
||||
return NextResponse.json({ error: error.message, details: error.details }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
const status = error.message === "Class not found" ? 404 : 400;
|
||||
return NextResponse.json({ error: error.message }, { status });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,116 +1,29 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createSession } from "@/lib/auth";
|
||||
import { loginSchema } from "@/lib/validation/authSchemas";
|
||||
import { checkRateLimit } from "@/lib/rateLimiter";
|
||||
|
||||
// Progressive lockout thresholds from Section 4
|
||||
function getLockoutDuration(failedAttempts: number): number {
|
||||
if (failedAttempts >= 20) return 24 * 60 * 60 * 1000; // 24 hours
|
||||
if (failedAttempts >= 15) return 30 * 60 * 1000; // 30 minutes
|
||||
if (failedAttempts >= 10) return 5 * 60 * 1000; // 5 minutes
|
||||
if (failedAttempts >= 5) return 1 * 60 * 1000; // 1 minute
|
||||
return 0;
|
||||
}
|
||||
import { login } from "@/services/authService";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Rate limit by IP
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
const ip = forwarded?.split(",")[0]?.trim() ?? "unknown";
|
||||
const rateCheck = checkRateLimit(ip);
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
if (!checkRateLimit(`login:${ip}`).allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests. Try again shortly." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body?.password || typeof body.password !== "string") {
|
||||
const parsed = loginSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Password is required" },
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid request" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check lockout state
|
||||
let security = await prisma.authSecurity.findUnique({ where: { id: 1 } });
|
||||
if (!security) {
|
||||
security = await prisma.authSecurity.create({ data: { id: 1 } });
|
||||
const result = await login(parsed.data.password);
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ error: result.error }, { status: result.status });
|
||||
}
|
||||
|
||||
if (security.lockedUntil && security.lockedUntil > new Date()) {
|
||||
const remainingMs =
|
||||
security.lockedUntil.getTime() - Date.now();
|
||||
const remainingMin = Math.ceil(remainingMs / 60000);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Account locked. Try again in ${remainingMin} minute${remainingMin !== 1 ? "s" : ""}.`,
|
||||
},
|
||||
{ status: 423 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const setting = await prisma.setting.findUnique({
|
||||
where: { key: "admin_password_hash" },
|
||||
});
|
||||
|
||||
let isValid = false;
|
||||
|
||||
if (!setting) {
|
||||
// Initial setup: hash and save the new password
|
||||
const argon2 = await import("argon2");
|
||||
const newHash = await argon2.hash(body.password);
|
||||
await prisma.setting.create({
|
||||
data: {
|
||||
key: "admin_password_hash",
|
||||
value: newHash,
|
||||
},
|
||||
});
|
||||
isValid = true;
|
||||
} else {
|
||||
// Verify existing password
|
||||
const passwordHash = setting.value;
|
||||
try {
|
||||
const argon2 = await import("argon2");
|
||||
isValid = await argon2.verify(passwordHash, body.password);
|
||||
} catch {
|
||||
isValid = body.password === passwordHash;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
const newFailedAttempts = security.failedAttempts + 1;
|
||||
const lockoutMs = getLockoutDuration(newFailedAttempts);
|
||||
const lockedUntil = lockoutMs > 0 ? new Date(Date.now() + lockoutMs) : null;
|
||||
|
||||
await prisma.authSecurity.update({
|
||||
where: { id: 1 },
|
||||
data: {
|
||||
failedAttempts: newFailedAttempts,
|
||||
lockedUntil,
|
||||
lastAttemptAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid password" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Success — reset lockout, create session
|
||||
await prisma.authSecurity.update({
|
||||
where: { id: 1 },
|
||||
data: {
|
||||
failedAttempts: 0,
|
||||
lockedUntil: null,
|
||||
lastAttemptAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await createSession();
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
|
|
|||
24
src/app/api/auth/password-reset/complete/route.ts
Normal file
24
src/app/api/auth/password-reset/complete/route.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { completePasswordResetSchema } from "@/lib/validation/authSchemas";
|
||||
import { completePasswordReset } from "@/services/authService";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const parsed = completePasswordResetSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid request" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await completePasswordReset(parsed.data.password))) {
|
||||
return NextResponse.json(
|
||||
{ error: "Reset authorization is invalid or expired" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
25
src/app/api/auth/password-reset/request/route.ts
Normal file
25
src/app/api/auth/password-reset/request/route.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { checkRateLimit } from "@/lib/rateLimiter";
|
||||
import { requestPasswordReset } from "@/services/authService";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
const rateLimit = checkRateLimit(`password-reset-request:${ip}`, {
|
||||
windowMs: 60_000,
|
||||
maxRequests: 1,
|
||||
});
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: "A reset token was requested recently. Try again shortly." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await requestPasswordReset())) {
|
||||
return NextResponse.json(
|
||||
{ error: "Password recovery is unavailable before initial setup." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
25
src/app/api/auth/password-reset/verify/route.ts
Normal file
25
src/app/api/auth/password-reset/verify/route.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { checkRateLimit } from "@/lib/rateLimiter";
|
||||
import { resetTokenSchema } from "@/lib/validation/authSchemas";
|
||||
import { verifyPasswordResetToken } from "@/services/authService";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
if (!checkRateLimit(`password-reset-verify:${ip}`).allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many attempts. Try again shortly." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = resetTokenSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid or expired reset token" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!(await verifyPasswordResetToken(parsed.data.token))) {
|
||||
return NextResponse.json({ error: "Invalid or expired reset token" }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
|
@ -1,19 +1,20 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { reorderRequestSchema } from "@/lib/validation/reorderSchemas";
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { items } = body;
|
||||
|
||||
if (!Array.isArray(items)) {
|
||||
const parsed = reorderRequestSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { items } = parsed.data;
|
||||
|
||||
// items should be an array of { id, sortOrder, groupId }
|
||||
// Using a transaction to perform all updates
|
||||
await prisma.$transaction(
|
||||
items.map((item: any) =>
|
||||
items.map((item) =>
|
||||
prisma.deck.update({
|
||||
where: { id: item.id },
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
|
|
@ -10,7 +11,7 @@ export async function PATCH(
|
|||
const body = await request.json();
|
||||
const { name, sortOrder } = body;
|
||||
|
||||
const updateData: any = {};
|
||||
const updateData: Prisma.MaterialGroupUpdateInput = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (sortOrder !== undefined) updateData.sortOrder = sortOrder;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
|
@ -14,7 +15,7 @@ export async function GET(request: NextRequest) {
|
|||
return NextResponse.json({ error: "Invalid type" }, { status: 400 });
|
||||
}
|
||||
|
||||
const whereClause: any = { classId };
|
||||
const whereClause: Prisma.MaterialGroupWhereInput = { classId };
|
||||
if (type) {
|
||||
whereClause.type = type;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { reorderRequestSchema } from "@/lib/validation/reorderSchemas";
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { items } = body;
|
||||
|
||||
if (!Array.isArray(items)) {
|
||||
const parsed = reorderRequestSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { items } = parsed.data;
|
||||
|
||||
// items should be an array of { id, sortOrder, groupId }
|
||||
// Using a transaction to perform all updates
|
||||
await prisma.$transaction(
|
||||
items.map((item: any) =>
|
||||
items.map((item) =>
|
||||
prisma.quizSet.update({
|
||||
where: { id: item.id },
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as settingsService from "@/services/settingsService";
|
||||
|
||||
function getInstructionType(request: NextRequest): settingsService.LlmInstructionType | null {
|
||||
const type = new URL(request.url).searchParams.get("type") ?? "flashcards";
|
||||
return type === "flashcards" || type === "quizzes" || type === "connections" || type === "crossword" ? type : null;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
|
||||
const type = getInstructionType(request);
|
||||
if (!type) return NextResponse.json({ error: "Invalid instruction type" }, { status: 400 });
|
||||
const instructions = await settingsService.getLlmInstructions(type);
|
||||
return NextResponse.json({ value: instructions });
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
|
||||
const type = getInstructionType(request);
|
||||
if (!type) return NextResponse.json({ error: "Invalid instruction type" }, { status: 400 });
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body || typeof body.value !== "string") {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi";
|
||||
import * as spacedRepetitionService from "@/services/spacedRepetitionService";
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
context: { params: Promise<{ id: string; deckId: string }> }
|
||||
) {
|
||||
const { id, deckId } = await context.params;
|
||||
try {
|
||||
await spacedRepetitionService.removeDeck(id, deckId);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return spacedRepetitionErrorResponse(error);
|
||||
}
|
||||
}
|
||||
43
src/app/api/spaced-repetition-sets/[id]/decks/route.ts
Normal file
43
src/app/api/spaced-repetition-sets/[id]/decks/route.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
addSpacedRepetitionDeckSchema,
|
||||
reorderSpacedRepetitionDecksSchema,
|
||||
} from "@/lib/validation/spacedRepetitionSchemas";
|
||||
import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi";
|
||||
import * as spacedRepetitionService from "@/services/spacedRepetitionService";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const parsed = addSpacedRepetitionDeckSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Invalid deck" }, { status: 400 });
|
||||
try {
|
||||
return NextResponse.json(
|
||||
await spacedRepetitionService.addDeck(id, parsed.data.deckId),
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
return spacedRepetitionErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const parsed = reorderSpacedRepetitionDecksSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Invalid deck order" }, { status: 400 });
|
||||
try {
|
||||
await spacedRepetitionService.reorderDecks(id, parsed.data.deckIds);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return spacedRepetitionErrorResponse(error);
|
||||
}
|
||||
}
|
||||
22
src/app/api/spaced-repetition-sets/[id]/reviews/route.ts
Normal file
22
src/app/api/spaced-repetition-sets/[id]/reviews/route.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { reviewSpacedRepetitionCardSchema } from "@/lib/validation/spacedRepetitionSchemas";
|
||||
import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi";
|
||||
import * as spacedRepetitionService from "@/services/spacedRepetitionService";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const parsed = reviewSpacedRepetitionCardSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Invalid review" }, { status: 400 });
|
||||
try {
|
||||
return NextResponse.json(
|
||||
await spacedRepetitionService.reviewCard({ setId: id, ...parsed.data })
|
||||
);
|
||||
} catch (error) {
|
||||
return spacedRepetitionErrorResponse(error);
|
||||
}
|
||||
}
|
||||
45
src/app/api/spaced-repetition-sets/[id]/route.ts
Normal file
45
src/app/api/spaced-repetition-sets/[id]/route.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { updateSpacedRepetitionSetSchema } from "@/lib/validation/spacedRepetitionSchemas";
|
||||
import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi";
|
||||
import * as spacedRepetitionService from "@/services/spacedRepetitionService";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
try {
|
||||
return NextResponse.json(await spacedRepetitionService.getSet(id));
|
||||
} catch (error) {
|
||||
return spacedRepetitionErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const parsed = updateSpacedRepetitionSetSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Invalid update" }, { status: 400 });
|
||||
try {
|
||||
return NextResponse.json(await spacedRepetitionService.updateSet(id, parsed.data));
|
||||
} catch (error) {
|
||||
return spacedRepetitionErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
try {
|
||||
await spacedRepetitionService.deleteSet(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return spacedRepetitionErrorResponse(error);
|
||||
}
|
||||
}
|
||||
15
src/app/api/spaced-repetition-sets/[id]/study/route.ts
Normal file
15
src/app/api/spaced-repetition-sets/[id]/study/route.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi";
|
||||
import * as spacedRepetitionService from "@/services/spacedRepetitionService";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
try {
|
||||
return NextResponse.json(await spacedRepetitionService.getStudyState(id));
|
||||
} catch (error) {
|
||||
return spacedRepetitionErrorResponse(error);
|
||||
}
|
||||
}
|
||||
30
src/app/api/spaced-repetition-sets/route.ts
Normal file
30
src/app/api/spaced-repetition-sets/route.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createSpacedRepetitionSetSchema } from "@/lib/validation/spacedRepetitionSchemas";
|
||||
import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi";
|
||||
import * as spacedRepetitionService from "@/services/spacedRepetitionService";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const classId = new URL(request.url).searchParams.get("classId");
|
||||
if (!classId) return NextResponse.json({ error: "classId is required" }, { status: 400 });
|
||||
try {
|
||||
return NextResponse.json(await spacedRepetitionService.listSetsByClass(classId));
|
||||
} catch (error) {
|
||||
return spacedRepetitionErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const parsed = createSpacedRepetitionSetSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid repetition set" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
return NextResponse.json(await spacedRepetitionService.createSet(parsed.data), {
|
||||
status: 201,
|
||||
});
|
||||
} catch (error) {
|
||||
return spacedRepetitionErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,64 +2,104 @@
|
|||
|
||||
@layer base {
|
||||
:root {
|
||||
--theme-bg-base: #f0f4f2;
|
||||
--theme-bg-surface: #ffffff;
|
||||
--theme-bg-surface-alt: #f4f7f5;
|
||||
--theme-bg-callout: #e8f4f0;
|
||||
--theme-primary: #2a7d7d;
|
||||
--theme-primary-hover: #1f6363;
|
||||
--theme-primary-light: #3a9e9e;
|
||||
--theme-text-heading: #1a2f2f;
|
||||
--theme-text-body: #2d4a4a;
|
||||
--theme-text-secondary: #4a6a6a;
|
||||
--theme-text-muted: #7a9a9a;
|
||||
--theme-border: #c8d8d0;
|
||||
--theme-border-light: #dce8e2;
|
||||
--theme-success: #2d8a4e;
|
||||
--theme-success-bg: #e8f5ed;
|
||||
--theme-error: #c44c4c;
|
||||
--theme-error-bg: #fce8e8;
|
||||
--theme-badge-bg: #e0eeea;
|
||||
--theme-badge-text: #2a6a5a;
|
||||
--theme-danger: #dc3545;
|
||||
--theme-danger-hover: #c82333;
|
||||
|
||||
--theme-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--theme-shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
--theme-shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.15), 0 8px 20px rgba(0, 0, 0, 0.1);
|
||||
--theme-bg-base: #f5f2ea;
|
||||
--theme-bg-surface: #fffdf8;
|
||||
--theme-bg-surface-alt: #eeeae0;
|
||||
--theme-bg-callout: #ebe9ff;
|
||||
--theme-primary: #4f46e5;
|
||||
--theme-primary-hover: #3f37c9;
|
||||
--theme-primary-light: #756ef0;
|
||||
--theme-accent: #f97360;
|
||||
--theme-text-heading: #172033;
|
||||
--theme-text-body: #344054;
|
||||
--theme-text-secondary: #667085;
|
||||
--theme-text-muted: #8b91a0;
|
||||
--theme-border: #d7d2c7;
|
||||
--theme-border-light: #e7e2d8;
|
||||
--theme-success: #25845f;
|
||||
--theme-success-bg: #e3f4ec;
|
||||
--theme-error: #c44747;
|
||||
--theme-error-bg: #fae8e5;
|
||||
--theme-badge-bg: #e8e5ff;
|
||||
--theme-badge-text: #4840bb;
|
||||
--theme-danger: #c44747;
|
||||
--theme-danger-hover: #a93636;
|
||||
--theme-shadow-card: 0 1px 2px rgba(23, 32, 51, 0.04), 0 8px 24px rgba(23, 32, 51, 0.055);
|
||||
--theme-shadow-card-hover: 0 2px 4px rgba(23, 32, 51, 0.05), 0 18px 40px rgba(23, 32, 51, 0.1);
|
||||
--theme-shadow-modal: 0 28px 80px rgba(23, 32, 51, 0.22);
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
--theme-bg-base: #121212;
|
||||
--theme-bg-surface: #1e1e1e;
|
||||
--theme-bg-surface-alt: #2d2d2d;
|
||||
--theme-bg-callout: #1a2723;
|
||||
--theme-primary: #4ab2b2;
|
||||
--theme-primary-hover: #5ec7c7;
|
||||
--theme-primary-light: #6cd1d1;
|
||||
--theme-text-heading: #f9fafb;
|
||||
--theme-text-body: #e5e7eb;
|
||||
--theme-text-secondary: #9ca3af;
|
||||
--theme-text-muted: #6b7280;
|
||||
--theme-border: #374151;
|
||||
--theme-border-light: #2d3748;
|
||||
--theme-success: #4ade80;
|
||||
--theme-success-bg: #064e3b;
|
||||
--theme-error: #f87171;
|
||||
--theme-error-bg: #7f1d1d;
|
||||
--theme-badge-bg: #1f2937;
|
||||
--theme-badge-text: #5ec7c7;
|
||||
--theme-danger: #ef4444;
|
||||
--theme-danger-hover: #f87171;
|
||||
--theme-bg-base: #0d1117;
|
||||
--theme-bg-surface: #151b23;
|
||||
--theme-bg-surface-alt: #1c2430;
|
||||
--theme-bg-callout: #25234a;
|
||||
--theme-primary: #8b83ff;
|
||||
--theme-primary-hover: #a49eff;
|
||||
--theme-primary-light: #b2adff;
|
||||
--theme-accent: #ff8a75;
|
||||
--theme-text-heading: #f6f3ec;
|
||||
--theme-text-body: #d8dde6;
|
||||
--theme-text-secondary: #aab3c0;
|
||||
--theme-text-muted: #7f8998;
|
||||
--theme-border: #354050;
|
||||
--theme-border-light: #293342;
|
||||
--theme-success: #58c596;
|
||||
--theme-success-bg: #173c30;
|
||||
--theme-error: #ff8177;
|
||||
--theme-error-bg: #472424;
|
||||
--theme-badge-bg: #2d2959;
|
||||
--theme-badge-text: #bbb7ff;
|
||||
--theme-danger: #ff8177;
|
||||
--theme-danger-hover: #ffa098;
|
||||
--theme-shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3), 0 10px 30px rgba(0, 0, 0, 0.18);
|
||||
--theme-shadow-card-hover: 0 2px 4px rgba(0, 0, 0, 0.36), 0 20px 44px rgba(0, 0, 0, 0.28);
|
||||
--theme-shadow-modal: 0 30px 90px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
--theme-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
--theme-shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.6), 0 2px 4px rgba(0, 0, 0, 0.4);
|
||||
--theme-shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.5), 0 8px 20px rgba(0, 0, 0, 0.3);
|
||||
* {
|
||||
border-color: var(--theme-border-light);
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--theme-bg-base);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
background:
|
||||
radial-gradient(circle at 88% -10%, color-mix(in srgb, var(--theme-primary) 9%, transparent), transparent 32rem),
|
||||
var(--theme-bg-base);
|
||||
color: var(--theme-text-body);
|
||||
font-family: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
button, a, input, textarea, select {
|
||||
outline-color: var(--theme-primary);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--theme-primary) 45%, transparent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
button, [role="button"], a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button:not(:disabled), [role="button"], a {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: color-mix(in srgb, var(--theme-primary) 24%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* ── Sage / Teal palette mapped from CSS variables ── */
|
||||
--color-bg-base: var(--theme-bg-base);
|
||||
--color-bg-surface: var(--theme-bg-surface);
|
||||
--color-bg-surface-alt: var(--theme-bg-surface-alt);
|
||||
|
|
@ -67,6 +107,7 @@
|
|||
--color-primary: var(--theme-primary);
|
||||
--color-primary-hover: var(--theme-primary-hover);
|
||||
--color-primary-light: var(--theme-primary-light);
|
||||
--color-accent: var(--theme-accent);
|
||||
--color-text-heading: var(--theme-text-heading);
|
||||
--color-text-body: var(--theme-text-body);
|
||||
--color-text-secondary: var(--theme-text-secondary);
|
||||
|
|
@ -81,123 +122,603 @@
|
|||
--color-badge-text: var(--theme-badge-text);
|
||||
--color-danger: var(--theme-danger);
|
||||
--color-danger-hover: var(--theme-danger-hover);
|
||||
|
||||
/* Typography */
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
/* Shadows */
|
||||
--font-sans: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-serif: var(--font-newsreader), ui-serif, Georgia, serif;
|
||||
--shadow-card: var(--theme-shadow-card);
|
||||
--shadow-card-hover: var(--theme-shadow-card-hover);
|
||||
--shadow-modal: var(--theme-shadow-modal);
|
||||
|
||||
/* Animations */
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--ease-spring: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
/* ── Base Styles ── */
|
||||
body {
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-body);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--color-border); border: 2px solid transparent; border-radius: 999px; background-clip: padding-box; }
|
||||
|
||||
.app-page {
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
margin-inline: auto;
|
||||
padding: 2rem clamp(1rem, 3vw, 3rem) 5rem;
|
||||
}
|
||||
|
||||
/* ── Scrollbar Styling ── */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
.editorial-title {
|
||||
font-family: var(--font-newsreader), Georgia, serif;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
|
||||
.paper-grid {
|
||||
background-image: radial-gradient(color-mix(in srgb, var(--theme-text-muted) 18%, transparent) 0.7px, transparent 0.7px);
|
||||
background-size: 18px 18px;
|
||||
}
|
||||
|
||||
.perspective-1000 { perspective: 1000px; }
|
||||
.preserve-3d { transform-style: preserve-3d; }
|
||||
.backface-hidden { backface-visibility: hidden; }
|
||||
.rotate-y-180 { transform: rotateY(180deg); }
|
||||
.rotate-x-180 { transform: rotateX(180deg); }
|
||||
|
||||
@keyframes swipeLeft { to { transform: translateX(-44px) rotate(-6deg); opacity: 0; } }
|
||||
@keyframes swipeRight { to { transform: translateX(44px) rotate(6deg); opacity: 0; } }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes slideUp { from { opacity: 0; transform: translateY(14px) scale(.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
@keyframes subtlePulse { 0%, 100% { opacity: 1; } 50% { opacity: .58; } }
|
||||
|
||||
.animate-swipe-left { animation: swipeLeft .36s var(--ease-spring) forwards; }
|
||||
.animate-swipe-right { animation: swipeRight .36s var(--ease-spring) forwards; }
|
||||
.animate-fade-in { animation: fadeIn .2s ease-out; }
|
||||
.animate-slide-up { animation: slideUp .32s var(--ease-spring); }
|
||||
.animate-subtle-pulse { animation: subtlePulse 1.7s ease-in-out infinite; }
|
||||
|
||||
/* Arcade routes take over the complete application shell, including navigation. */
|
||||
body:has(.arcade-route) {
|
||||
--theme-bg-base: #070b18;
|
||||
--theme-bg-surface: #11182a;
|
||||
--theme-bg-surface-alt: #19233a;
|
||||
--theme-bg-callout: #282357;
|
||||
--theme-primary: #9a8cff;
|
||||
--theme-primary-hover: #b5aaff;
|
||||
--theme-text-heading: #f9f7ff;
|
||||
--theme-text-body: #dbe2f4;
|
||||
--theme-text-secondary: #aab6d1;
|
||||
--theme-text-muted: #71809e;
|
||||
--theme-border: #34415c;
|
||||
--theme-border-light: #242f46;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 76% 4%, rgba(116, 91, 255, .2), transparent 28rem),
|
||||
radial-gradient(circle at 30% 92%, rgba(5, 198, 181, .11), transparent 34rem),
|
||||
linear-gradient(145deg, #080c19, #0c1221 58%, #0b1020);
|
||||
}
|
||||
|
||||
body:has(.arcade-route)::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
opacity: .11;
|
||||
background-image: radial-gradient(rgba(201, 211, 255, .7) .7px, transparent .7px);
|
||||
background-size: 22px 22px;
|
||||
}
|
||||
|
||||
body:has(.arcade-route) main { min-height: 100vh; }
|
||||
body:has(.arcade-route) .app-page { max-width: none; min-height: 100vh; }
|
||||
body:has(.arcade-route) .arcade-route { width: min(100%, 1360px); margin-inline: auto; }
|
||||
body:has(.arcade-route) .app-sidebar,
|
||||
body:has(.arcade-route) .app-mobile-header {
|
||||
border-color: rgba(138, 157, 201, .15);
|
||||
background: rgba(8, 13, 27, .9);
|
||||
box-shadow: 18px 0 55px rgba(0, 0, 0, .16);
|
||||
}
|
||||
|
||||
body:has(.connections-world) {
|
||||
background:
|
||||
radial-gradient(circle at 28% 0%, rgba(88, 101, 242, .34), transparent 36rem),
|
||||
radial-gradient(circle at 96% 28%, rgba(239, 71, 111, .16), transparent 30rem),
|
||||
linear-gradient(145deg, #0b1329, #0e1932 54%, #142440);
|
||||
}
|
||||
|
||||
.arcade-lobby-heading { position: relative; }
|
||||
.arcade-lobby-heading::after {
|
||||
content: "SELECT GAME";
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: .5rem;
|
||||
color: rgba(154, 140, 255, .08);
|
||||
font-size: clamp(3rem, 8vw, 7rem);
|
||||
font-weight: 950;
|
||||
letter-spacing: -.06em;
|
||||
line-height: .8;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.arcade-cabinet {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 30rem;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 202, .2);
|
||||
border-radius: 1.75rem;
|
||||
background: #11182a;
|
||||
box-shadow: inset 0 1px rgba(255,255,255,.06), 0 22px 55px rgba(0,0,0,.28);
|
||||
transition: transform .24s var(--ease-spring), box-shadow .24s ease, border-color .24s ease;
|
||||
}
|
||||
.arcade-cabinet.is-playable:hover { transform: translateY(-7px) rotate(-.3deg); border-color: rgba(181,170,255,.65); box-shadow: 0 30px 70px rgba(0,0,0,.38), 0 0 45px rgba(126,103,255,.13); }
|
||||
.arcade-cabinet.is-coming { filter: saturate(.82); }
|
||||
.arcade-visual { position: relative; height: 13.5rem; overflow: hidden; border-bottom: 1px solid rgba(255,255,255,.1); }
|
||||
.arcade-cabinet-copy { display: flex; flex: 1; flex-direction: column; padding: 1.25rem; }
|
||||
.arcade-cabinet-status { font-size: .65rem; font-weight: 900; letter-spacing: .18em; text-transform: uppercase; }
|
||||
.arcade-cabinet-copy h3 { margin-top: .35rem; color: white; font-size: 1.55rem; font-weight: 900; letter-spacing: -.04em; }
|
||||
.arcade-cabinet-copy > p:not(.arcade-cabinet-status) { margin-top: .55rem; flex: 1; color: #aab6d1; font-size: .86rem; line-height: 1.65; }
|
||||
.arcade-cabinet-action { display: grid; min-height: 2.9rem; margin-top: 1.1rem; place-items: center; border: 1px solid rgba(255,255,255,.13); border-radius: .9rem; color: #8d9ab5; background: rgba(5,9,20,.3); font-size: .8rem; font-weight: 850; }
|
||||
.is-playable .arcade-cabinet-action { color: #14152a; border-color: transparent; background: linear-gradient(135deg, #a89dff, #8170f5); box-shadow: 0 10px 28px rgba(126,103,255,.28); }
|
||||
|
||||
.connections-visual { display: grid; place-items: center; background: radial-gradient(circle at 50% 20%, rgba(180,162,255,.35), transparent 55%), linear-gradient(145deg,#342c64,#1d2042); }
|
||||
.connections-visual-grid { display: grid; width: 9.5rem; grid-template-columns: repeat(4, 1fr); gap: .38rem; transform: perspective(400px) rotateX(8deg) rotateZ(-2deg); }
|
||||
.connections-visual-grid i { aspect-ratio: 1; border-radius: .5rem; box-shadow: inset 0 1px rgba(255,255,255,.45), 0 5px 10px rgba(0,0,0,.22); }
|
||||
.connections-visual-grid i:nth-child(-n+4) { background: #ffd166; }
|
||||
.connections-visual-grid i:nth-child(n+5):nth-child(-n+8) { background: #5ee0a0; }
|
||||
.connections-visual-grid i:nth-child(n+9):nth-child(-n+12) { background: #5ac8fa; }
|
||||
.connections-visual-grid i:nth-child(n+13) { background: #b69cff; }
|
||||
.connections-visual-label { position: absolute; right: 1rem; bottom: .75rem; color: rgba(255,255,255,.6); font-size: .62rem; font-weight: 900; letter-spacing: .16em; text-transform: uppercase; }
|
||||
|
||||
.crossword-visual { display: grid; place-items: center; background: linear-gradient(150deg,#f4dca1,#c98c45); }
|
||||
.crossword-visual::before { content: ""; position: absolute; inset: 0; opacity: .12; background-image: linear-gradient(#412911 1px,transparent 1px),linear-gradient(90deg,#412911 1px,transparent 1px); background-size: 18px 18px; }
|
||||
.crossword-grid { display: grid; width: 10rem; grid-template-columns: repeat(8,1fr); gap: 2px; transform: rotate(-3deg); filter: drop-shadow(0 12px 12px rgba(71,38,10,.28)); }
|
||||
.crossword-grid i { display: grid; aspect-ratio: 1; place-items: center; background: #312419; color: transparent; font-size: .62rem; font-style: normal; font-weight: 950; }
|
||||
.crossword-grid i.has-letter { color: #322112; background: #fff9e9; }
|
||||
.crossword-pencil { position: absolute; right: 1.2rem; bottom: .55rem; color: #6d3e17; font-size: 3rem; transform: rotate(-24deg); text-shadow: 0 5px 8px rgba(64,35,10,.2); }
|
||||
.arcade-cabinet-crossword .arcade-cabinet-status { color: #f3bb62; }
|
||||
|
||||
.blocks-visual { background: linear-gradient(#071a20,#0a3436); }
|
||||
.blocks-visual::before { content: ""; position: absolute; inset: 0; opacity: .18; background-image: linear-gradient(rgba(74,222,128,.45) 1px,transparent 1px),linear-gradient(90deg,rgba(74,222,128,.45) 1px,transparent 1px); background-size: 24px 24px; }
|
||||
.blocks-piece { position: absolute; display: grid; gap: 3px; }
|
||||
.blocks-piece i,.blocks-floor i { border: 1px solid rgba(255,255,255,.35); border-radius: 4px; box-shadow: inset 0 0 8px rgba(255,255,255,.25),0 0 13px currentColor; }
|
||||
.blocks-piece-a { left: 32%; top: 18%; grid-template-columns: repeat(2,1.8rem); color: #38e6c5; animation: arcadeBlockHover 2s ease-in-out infinite; }
|
||||
.blocks-piece-a i { width: 1.8rem; height: 1.8rem; background: #0fcbaa; }
|
||||
.blocks-piece-b { right: 18%; top: 42%; grid-template-columns: repeat(3,1.55rem); color: #ff4f91; transform: rotate(90deg); }
|
||||
.blocks-piece-b i { width: 1.55rem; height: 1.55rem; background: #ed397e; }
|
||||
.blocks-floor { position: absolute; right: 12%; bottom: 1rem; left: 12%; display: grid; grid-template-columns: repeat(6,1fr); gap: 3px; }
|
||||
.blocks-floor i { aspect-ratio: 1; color: #ffc857; background: #eaaa2e; }
|
||||
.blocks-floor i:nth-child(3n) { color: #6c7cff; background: #5868ee; transform: translateY(-1.5rem); }
|
||||
.blocks-arrow { position: absolute; left: 19%; top: 20%; color: rgba(103,232,211,.5); font-size: 2.2rem; animation: arcadeArrowDown 1.3s ease-in-out infinite; }
|
||||
.arcade-cabinet-falling-blocks .arcade-cabinet-status { color: #49ddb6; }
|
||||
|
||||
.asteroid-visual { background: radial-gradient(circle at 72% 28%,#253c74,#101735 45%,#070b19); }
|
||||
.arcade-star { position: absolute; width: 3px; height: 3px; border-radius: 50%; background: white; box-shadow: 0 0 8px white; }
|
||||
.star-a { left: 16%; top: 18%; }.star-b { right: 18%; top: 12%; }.star-c { left: 48%; top: 39%; }
|
||||
.asteroid { position: absolute; border: 2px solid #8c91a6; border-radius: 47% 53% 42% 58%; background: radial-gradient(circle at 32% 28%,#9299ac,#4c5268 62%,#292e41); box-shadow: inset -7px -8px 12px rgba(0,0,0,.35),0 8px 18px rgba(0,0,0,.35); }
|
||||
.asteroid::after { content: ""; position: absolute; width: 28%; height: 24%; left: 18%; top: 22%; border-radius: 50%; background: rgba(32,37,55,.45); }
|
||||
.asteroid-a { width: 3.2rem; height: 3rem; right: 14%; top: 18%; transform: rotate(18deg); }.asteroid-b { width: 2rem; height: 2rem; left: 16%; top: 27%; }.asteroid-c { width: 1.4rem; height: 1.35rem; right: 32%; bottom: 18%; }
|
||||
.space-station { position: absolute; left: 20%; bottom: 18%; width: 4rem; height: 1.4rem; border-radius: 65% 20% 35% 65%; background: linear-gradient(90deg,#b9c8ef,#6f83bd); transform: rotate(-12deg); box-shadow: 0 0 20px rgba(105,151,255,.35); }
|
||||
.space-station::before { content: ""; position: absolute; left: 1.2rem; bottom: 1rem; border-right: 1rem solid transparent; border-bottom: 1.5rem solid #53699f; border-left: .25rem solid transparent; }
|
||||
.space-station i { position: absolute; right: -.75rem; top: .3rem; width: 1rem; height: .8rem; border-radius: 50%; background: #50e6ff; box-shadow: 0 0 16px #4fdff8; }
|
||||
.laser-beam { position: absolute; width: 38%; height: 2px; left: 42%; top: 52%; background: linear-gradient(90deg,#70f4ff,transparent); transform: rotate(-20deg); transform-origin: left; box-shadow: 0 0 8px #52e7ff; }
|
||||
.shield-ring { position: absolute; left: 8%; bottom: 4%; width: 7.5rem; height: 6rem; border: 2px solid rgba(90,221,255,.25); border-radius: 50%; transform: rotate(-15deg); }
|
||||
.arcade-cabinet-asteroid-defense .arcade-cabinet-status { color: #65c9ff; }
|
||||
|
||||
@keyframes arcadeBlockHover { 0%,100% { transform: translateY(0); } 50% { transform: translateY(8px); } }
|
||||
@keyframes arcadeArrowDown { 0%,100% { opacity: .3; transform: translateY(-5px); } 50% { opacity: .8; transform: translateY(6px); } }
|
||||
|
||||
/* Connections has its own playful game-show visual system, independent of the desk UI. */
|
||||
.connections-world {
|
||||
--theme-bg-surface: #182443;
|
||||
--theme-bg-surface-alt: #223154;
|
||||
--theme-bg-callout: #2c3d68;
|
||||
--theme-primary: #ffd166;
|
||||
--theme-primary-hover: #ffe29a;
|
||||
--theme-text-heading: #f8fbff;
|
||||
--theme-text-body: #dbe7ff;
|
||||
--theme-text-secondary: #afc0df;
|
||||
--theme-text-muted: #8497bd;
|
||||
--theme-border: #52668d;
|
||||
--theme-border-light: #33486f;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
color: var(--theme-text-body);
|
||||
background:
|
||||
radial-gradient(circle at 10% 0%, rgba(88, 101, 242, .45), transparent 28rem),
|
||||
radial-gradient(circle at 95% 25%, rgba(239, 71, 111, .2), transparent 25rem),
|
||||
linear-gradient(145deg, #101a35 0%, #111d39 48%, #172846 100%);
|
||||
box-shadow: 0 28px 80px rgba(8, 15, 34, .28);
|
||||
}
|
||||
|
||||
.connections-hub.connections-world,
|
||||
.connections-stage.connections-world {
|
||||
overflow: visible;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-muted);
|
||||
.connections-hub.connections-world::before,
|
||||
.connections-stage.connections-world::before { display: none; }
|
||||
|
||||
.connections-world::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
opacity: .13;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,.18) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.18) 1px, transparent 1px);
|
||||
background-size: 42px 42px;
|
||||
mask-image: linear-gradient(to bottom, black, transparent 78%);
|
||||
}
|
||||
|
||||
/* ── Card flip animation ── */
|
||||
.perspective-1000 {
|
||||
perspective: 1000px;
|
||||
.connections-world .editorial-title {
|
||||
font-family: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
|
||||
font-weight: 850;
|
||||
letter-spacing: -.045em;
|
||||
}
|
||||
|
||||
.preserve-3d {
|
||||
transform-style: preserve-3d;
|
||||
.connections-hub-hero,
|
||||
.connections-topbar,
|
||||
.connections-status,
|
||||
.connections-setup,
|
||||
.connections-import {
|
||||
backdrop-filter: blur(18px);
|
||||
box-shadow: inset 0 1px rgba(255,255,255,.08), 0 18px 45px rgba(2, 8, 23, .22);
|
||||
}
|
||||
|
||||
.backface-hidden {
|
||||
backface-visibility: hidden;
|
||||
.connections-hub-hero {
|
||||
background: linear-gradient(120deg, rgba(34,49,84,.94), rgba(24,36,67,.72));
|
||||
border: 1px solid rgba(151, 174, 221, .18);
|
||||
}
|
||||
|
||||
.rotate-y-180 {
|
||||
transform: rotateY(180deg);
|
||||
.connections-pack-card {
|
||||
border-color: rgba(117, 141, 187, .28);
|
||||
background: rgba(24, 36, 67, .74);
|
||||
box-shadow: inset 0 1px rgba(255,255,255,.05), 0 12px 30px rgba(3, 9, 24, .16);
|
||||
}
|
||||
|
||||
.rotate-x-180 {
|
||||
transform: rotateX(180deg);
|
||||
.connections-pack-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(255, 209, 102, .48);
|
||||
}
|
||||
|
||||
/* ── Swipe animations ── */
|
||||
@keyframes swipeLeft {
|
||||
0% { transform: translateX(0) rotate(0deg); opacity: 1; }
|
||||
100% { transform: translateX(-40px) rotate(-8deg); opacity: 0; }
|
||||
.connections-pack-card.is-active {
|
||||
border-color: #ffd166;
|
||||
background: linear-gradient(120deg, rgba(65, 72, 132, .9), rgba(38, 55, 96, .9));
|
||||
box-shadow: 0 0 0 1px rgba(255,209,102,.25), 0 18px 45px rgba(3,9,24,.25);
|
||||
}
|
||||
|
||||
@keyframes swipeRight {
|
||||
0% { transform: translateX(0) rotate(0deg); opacity: 1; }
|
||||
100% { transform: translateX(40px) rotate(8deg); opacity: 0; }
|
||||
.connections-primary-button {
|
||||
color: #172033;
|
||||
background: linear-gradient(135deg, #ffd166, #ffb84d);
|
||||
box-shadow: 0 10px 26px rgba(255, 184, 77, .24), inset 0 1px rgba(255,255,255,.5);
|
||||
transition: transform .18s var(--ease-spring), filter .18s ease, box-shadow .18s ease;
|
||||
}
|
||||
|
||||
.animate-swipe-left {
|
||||
animation: swipeLeft 0.4s var(--ease-spring) forwards;
|
||||
.connections-primary-button:not(:disabled):hover {
|
||||
transform: translateY(-2px) scale(1.01);
|
||||
filter: brightness(1.06);
|
||||
box-shadow: 0 14px 34px rgba(255, 184, 77, .32), inset 0 1px rgba(255,255,255,.6);
|
||||
}
|
||||
|
||||
.animate-swipe-right {
|
||||
animation: swipeRight 0.4s var(--ease-spring) forwards;
|
||||
.connections-secondary-button {
|
||||
color: #dbe7ff;
|
||||
border: 1px solid #52668d;
|
||||
background: rgba(24, 36, 67, .82);
|
||||
transition: transform .18s var(--ease-spring), background .18s ease;
|
||||
}
|
||||
|
||||
/* ── Modal animation ── */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
.connections-secondary-button:not(:disabled):hover { transform: translateY(-2px); background: #2c3d68; }
|
||||
|
||||
.connections-tile {
|
||||
color: #15213a;
|
||||
border: 1px solid rgba(255,255,255,.75);
|
||||
background: linear-gradient(145deg, #fffaf0, #e9effa);
|
||||
box-shadow: 0 7px 0 #aebbd2, 0 12px 24px rgba(2,8,23,.24), inset 0 1px #fff;
|
||||
animation: connectionsTileIn .36s var(--ease-spring) both;
|
||||
transition: transform .16s var(--ease-spring), box-shadow .16s ease, color .16s ease, background .16s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(20px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
.connections-tile:hover { transform: translateY(-3px); box-shadow: 0 9px 0 #aebbd2, 0 16px 30px rgba(2,8,23,.28), inset 0 1px #fff; }
|
||||
.connections-tile.is-selected {
|
||||
color: #fff;
|
||||
border-color: #8c7cf7;
|
||||
background: linear-gradient(145deg, #735df2, #5540ca);
|
||||
box-shadow: 0 5px 0 #30228e, 0 12px 28px rgba(84,64,202,.4), inset 0 1px rgba(255,255,255,.28);
|
||||
transform: translateY(2px) scale(.975);
|
||||
animation: connectionsTileSelect .25s var(--ease-spring);
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
.connections-group,
|
||||
.connections-review-card {
|
||||
color: #172033;
|
||||
box-shadow: inset 0 1px rgba(255,255,255,.55), 0 10px 26px rgba(2,8,23,.2);
|
||||
}
|
||||
.connections-group .text-text-heading,
|
||||
.connections-group .text-text-secondary,
|
||||
.connections-review-card .text-text-heading,
|
||||
.connections-review-card .text-text-secondary { color: #172033; }
|
||||
.connections-group { animation: connectionsGroupReveal .56s var(--ease-spring) both; }
|
||||
.connections-group-0 { background: linear-gradient(135deg, #ffd166, #f7b944); }
|
||||
.connections-group-1 { background: linear-gradient(135deg, #70e1a1, #36bd7c); }
|
||||
.connections-group-2 { background: linear-gradient(135deg, #72d5f7, #4aa8e8); }
|
||||
.connections-group-3 { background: linear-gradient(135deg, #b9a2ff, #8d73ed); }
|
||||
|
||||
.connections-results-hero {
|
||||
background: linear-gradient(130deg, #5b46d8, #283d77 58%, #164e63);
|
||||
box-shadow: inset 0 1px rgba(255,255,255,.18), 0 22px 50px rgba(2,8,23,.3);
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s var(--ease-spring);
|
||||
.connections-completion {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: 32rem;
|
||||
place-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: 1.75rem;
|
||||
text-align: center;
|
||||
background: radial-gradient(circle, rgba(94,234,212,.2), transparent 42%), rgba(10,18,39,.72);
|
||||
animation: connectionsCompletionIn .55s var(--ease-spring) both;
|
||||
}
|
||||
.connections-completion-mark {
|
||||
display: grid;
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
margin: 0 auto 1.5rem;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
color: #172033;
|
||||
background: #ffd166;
|
||||
box-shadow: 0 0 0 12px rgba(255,209,102,.12), 0 0 65px rgba(255,209,102,.46);
|
||||
font-size: 3rem;
|
||||
font-weight: 900;
|
||||
animation: connectionsWinMark .8s .15s var(--ease-spring) both;
|
||||
}
|
||||
.connections-completion p { color: #8ee7d1; font-size: .75rem; font-weight: 850; letter-spacing: .2em; text-transform: uppercase; }
|
||||
.connections-completion h2 { margin-top: .35rem; color: white; font-size: clamp(2.5rem, 8vw, 5.5rem); font-weight: 900; letter-spacing: -.065em; line-height: .95; }
|
||||
.connections-completion span { margin-top: 1rem; color: #b9c8e5; font-weight: 700; }
|
||||
|
||||
.connections-confetti { position: absolute; inset: 0; pointer-events: none; }
|
||||
.connections-confetti i {
|
||||
position: absolute;
|
||||
top: -8%;
|
||||
left: var(--confetti-x);
|
||||
width: 9px;
|
||||
height: 17px;
|
||||
border-radius: 2px;
|
||||
background: var(--confetti-color);
|
||||
animation: connectionsConfettiFall 1.45s var(--confetti-delay) cubic-bezier(.2,.7,.3,1) both;
|
||||
}
|
||||
|
||||
/* ── Subtle pulse for loading states ── */
|
||||
@keyframes subtlePulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
@keyframes connectionsTileIn { from { opacity: 0; transform: translateY(16px) scale(.94); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
@keyframes connectionsTileSelect { 0% { transform: scale(1); } 55% { transform: translateY(3px) scale(.94); } 100% { transform: translateY(2px) scale(.975); } }
|
||||
@keyframes connectionsShake { 0%,100% { transform: translateX(0); } 20% { transform: translateX(-9px); } 40% { transform: translateX(8px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(3px); } }
|
||||
@keyframes connectionsGroupReveal { from { opacity: 0; transform: scale(.9) translateY(18px); filter: brightness(1.6); } 65% { transform: scale(1.025) translateY(-2px); } to { opacity: 1; transform: scale(1) translateY(0); filter: brightness(1); } }
|
||||
@keyframes connectionsCompletionIn { from { opacity: 0; transform: scale(.94); } to { opacity: 1; transform: scale(1); } }
|
||||
@keyframes connectionsWinMark { from { opacity: 0; transform: scale(.2) rotate(-25deg); } 70% { transform: scale(1.12) rotate(4deg); } to { opacity: 1; transform: scale(1) rotate(0); } }
|
||||
@keyframes connectionsConfettiFall { 0% { opacity: 0; transform: translateY(-1rem) rotate(0); } 10% { opacity: 1; } 100% { opacity: 1; transform: translateY(38rem) rotate(var(--confetti-spin)); } }
|
||||
@keyframes connectionsResultsIn { from { opacity: 0; transform: translateY(22px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
.animate-connections-shake { animation: connectionsShake .42s ease-in-out; }
|
||||
.animate-connections-results-in { animation: connectionsResultsIn .52s var(--ease-spring) both; }
|
||||
|
||||
/* Crossword turns the full arcade shell into a warm, ink-and-paper newsroom. */
|
||||
body:has(.crossword-world) {
|
||||
--theme-bg-base: #20170f;
|
||||
--theme-bg-surface: #f6ecd2;
|
||||
--theme-bg-surface-alt: #ead9b5;
|
||||
--theme-bg-callout: #dfc795;
|
||||
--theme-primary: #9a5a24;
|
||||
--theme-primary-hover: #7b431b;
|
||||
--theme-text-heading: #2d2117;
|
||||
--theme-text-body: #493725;
|
||||
--theme-text-secondary: #68523a;
|
||||
--theme-text-muted: #8a7052;
|
||||
--theme-border: #a9865d;
|
||||
--theme-border-light: #cfb88e;
|
||||
background:
|
||||
radial-gradient(circle at 18% 8%, rgba(230, 181, 88, .18), transparent 28rem),
|
||||
linear-gradient(135deg, #1d140d, #3c2818 55%, #21160e);
|
||||
}
|
||||
body:has(.crossword-world)::before {
|
||||
opacity: .16;
|
||||
background-image: repeating-linear-gradient(0deg, transparent 0 3px, rgba(255,236,195,.16) 4px);
|
||||
}
|
||||
body:has(.crossword-world) .app-sidebar,
|
||||
body:has(.crossword-world) .app-mobile-header {
|
||||
--theme-bg-surface-alt: #3a2819;
|
||||
--theme-bg-callout: #e4cc98;
|
||||
--theme-primary: #8a4f20;
|
||||
--theme-text-heading: #fff3d8;
|
||||
--theme-text-secondary: #d7c3a2;
|
||||
--theme-text-muted: #aa9170;
|
||||
--theme-border-light: #5b4028;
|
||||
color: #f8ead0;
|
||||
border-color: rgba(224, 190, 126, .22);
|
||||
background: rgba(35, 23, 14, .94);
|
||||
}
|
||||
|
||||
.animate-subtle-pulse {
|
||||
animation: subtlePulse 2s ease-in-out infinite;
|
||||
.crossword-world {
|
||||
position: relative;
|
||||
color: var(--theme-text-body);
|
||||
}
|
||||
.crossword-hub.crossword-world,
|
||||
.crossword-stage.crossword-world { overflow: visible; }
|
||||
.crossword-hero,
|
||||
.crossword-setup,
|
||||
.crossword-active-clue,
|
||||
.crossword-clues,
|
||||
.crossword-import,
|
||||
.crossword-paper {
|
||||
border: 1px solid rgba(100, 68, 35, .24);
|
||||
background:
|
||||
linear-gradient(rgba(255,255,255,.18), rgba(255,255,255,0)),
|
||||
repeating-linear-gradient(0deg, rgba(83,54,24,.025) 0 1px, transparent 1px 4px),
|
||||
#f4e7c9;
|
||||
box-shadow: inset 0 1px rgba(255,255,255,.7), 0 18px 48px rgba(15,9,4,.25);
|
||||
}
|
||||
.crossword-hero { position: relative; overflow: hidden; }
|
||||
.crossword-hero > * { position: relative; z-index: 1; }
|
||||
.crossword-hero::after {
|
||||
content: "DAILY STUDY";
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: .35rem;
|
||||
color: rgba(77,51,28,.07);
|
||||
font-family: Georgia, serif;
|
||||
font-size: clamp(2.75rem, 5vw, 5rem);
|
||||
font-weight: 900;
|
||||
letter-spacing: -.06em;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.crossword-kicker,
|
||||
.crossword-section-label {
|
||||
color: #925623;
|
||||
font-size: .68rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: .18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.crossword-section-label { display: block; margin-bottom: .7rem; color: var(--theme-text-muted); }
|
||||
.crossword-primary {
|
||||
color: #fff8e7;
|
||||
background: linear-gradient(135deg, #a86429, #744018);
|
||||
box-shadow: inset 0 1px rgba(255,255,255,.24), 0 7px 0 #4a2912, 0 12px 24px rgba(49,27,12,.22);
|
||||
transition: transform .16s var(--ease-spring), filter .16s ease;
|
||||
}
|
||||
.crossword-primary:hover { transform: translateY(-2px); filter: brightness(1.08); }
|
||||
.crossword-primary:active { transform: translateY(4px); box-shadow: inset 0 1px rgba(255,255,255,.2), 0 3px 0 #4a2912; }
|
||||
.crossword-pack {
|
||||
border-color: rgba(111,76,43,.25);
|
||||
background: rgba(244,231,201,.92);
|
||||
box-shadow: 0 10px 26px rgba(20,12,6,.16), inset 0 1px rgba(255,255,255,.65);
|
||||
transition: transform .18s ease, border-color .18s ease;
|
||||
}
|
||||
.crossword-pack:hover { transform: translateY(-2px); border-color: #a86429; }
|
||||
.crossword-pack.is-active { border-color: #8d511f; box-shadow: 0 0 0 2px rgba(168,100,41,.2), 0 14px 30px rgba(20,12,6,.2); }
|
||||
.crossword-paper > div i { aspect-ratio: 1; border: 1px solid rgba(45,33,23,.3); }
|
||||
.crossword-size {
|
||||
color: var(--theme-text-secondary);
|
||||
border: 1px solid var(--theme-border-light);
|
||||
background: rgba(255,250,235,.48);
|
||||
}
|
||||
.crossword-size.is-active { color: #2d2117; border-color: #955720; background: #e4c993; box-shadow: inset 0 0 0 2px rgba(255,255,255,.35); }
|
||||
.crossword-board-preview { overflow: hidden; border: 1px solid #c9aa78; border-radius: .9rem; background: rgba(255,250,235,.48); }
|
||||
.crossword-preview-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .65rem .8rem; border-bottom: 1px solid #d5bd93; color: #5d4630; }
|
||||
.crossword-preview-heading span { font-size: .68rem; font-weight: 900; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.crossword-preview-heading small { color: #8a7052; font-size: .68rem; }
|
||||
.crossword-preview-canvas { display: grid; min-height: 9rem; place-items: center; overflow: hidden; padding: .75rem; background: #322116; }
|
||||
.crossword-preview-grid { display: grid; filter: drop-shadow(0 6px 9px rgba(0,0,0,.35)); }
|
||||
.crossword-preview-grid i { border: .5px solid #9e8058; background: #fff6de; }
|
||||
.crossword-preview-loading,.crossword-preview-canvas > p { color: #d7c3a2; font-size: .75rem; font-weight: 800; }
|
||||
.crossword-option { display: flex; align-items: flex-start; gap: .7rem; padding: .75rem; border-radius: .75rem; background: rgba(224,202,157,.38); }
|
||||
.crossword-option input { margin-top: .2rem; accent-color: #925623; }
|
||||
.crossword-option strong,.crossword-option small { display: block; }
|
||||
.crossword-option small { margin-top: .12rem; color: var(--theme-text-muted); }
|
||||
.crossword-active-clue { display: grid; gap: .2rem; }
|
||||
.crossword-active-clue span { color: #925623; font-size: .68rem; font-weight: 900; letter-spacing: .15em; text-transform: uppercase; }
|
||||
.crossword-active-clue strong { color: #2d2117; font-family: Georgia, serif; font-size: 1.05rem; }
|
||||
.crossword-active-clue small { color: var(--theme-text-muted); }
|
||||
.crossword-toolbar { color: #f4e7c9; background: rgba(35,23,14,.9); box-shadow: 0 8px 24px rgba(13,8,4,.24); }
|
||||
.crossword-toolbar > div { display: flex; align-items: center; gap: .35rem; }
|
||||
.crossword-toolbar button { display: grid; width: 2rem; height: 2rem; place-items: center; border: 1px solid rgba(245,222,179,.25); border-radius: .45rem; font-weight: 900; }
|
||||
.crossword-toolbar span { min-width: 3rem; text-align: center; font-size: .75rem; font-weight: 800; }
|
||||
.crossword-board-viewport {
|
||||
max-height: min(68vh, 46rem);
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
overscroll-behavior: contain;
|
||||
background: #2a1d13;
|
||||
box-shadow: inset 0 0 0 5px #4c321e, inset 0 0 28px rgba(0,0,0,.7), 0 16px 38px rgba(13,8,4,.3);
|
||||
}
|
||||
.crossword-board { display: grid; width: max-content; min-width: 100%; min-height: 20rem; place-content: center; }
|
||||
.crossword-cell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: var(--crossword-cell);
|
||||
height: var(--crossword-cell);
|
||||
place-items: center;
|
||||
color: #25190f;
|
||||
border: 1px solid #6f5335;
|
||||
background: #fff8e6;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
transition: background .12s ease, box-shadow .12s ease;
|
||||
}
|
||||
.crossword-cell small { position: absolute; left: 2px; top: 0; font-size: max(7px, calc(var(--crossword-cell) * .22)); line-height: 1; }
|
||||
.crossword-cell strong { font-size: max(12px, calc(var(--crossword-cell) * .56)); }
|
||||
.crossword-cell.is-word { background: #f2d99f; }
|
||||
.crossword-cell.is-active { z-index: 2; background: #ffe27e; box-shadow: inset 0 0 0 3px #925623; }
|
||||
.crossword-cell.is-wrong::after { content: ""; position: absolute; right: 3px; bottom: 3px; width: 7px; height: 7px; border: 2px solid #a62e25; border-radius: 50%; }
|
||||
.crossword-cell.is-revealed strong { color: #8a5a24; text-decoration: underline dotted; }
|
||||
.crossword-secondary {
|
||||
min-height: 2.75rem;
|
||||
padding: .6rem .9rem;
|
||||
border: 1px solid #9f7a50;
|
||||
border-radius: .7rem;
|
||||
color: #392718;
|
||||
background: #ead6ae;
|
||||
font-size: .78rem;
|
||||
font-weight: 850;
|
||||
box-shadow: 0 3px 0 #aa895f;
|
||||
}
|
||||
.crossword-secondary:hover { background: #f5e5c4; }
|
||||
.crossword-clues { max-height: 72vh; overflow: hidden; }
|
||||
.crossword-clues > div button { min-height: 2.5rem; border-bottom: 2px solid transparent; color: var(--theme-text-muted); font-size: .75rem; font-weight: 900; text-transform: uppercase; }
|
||||
.crossword-clues > div button.is-active { color: #7d461d; border-color: #9a5a24; }
|
||||
.crossword-clues ol { max-height: calc(72vh - 5rem); overflow-y: auto; padding-right: .25rem; }
|
||||
.crossword-clues li button { display: grid; width: 100%; grid-template-columns: 2rem 1fr; gap: .4rem; padding: .55rem; border-radius: .5rem; text-align: left; color: #4b3826; }
|
||||
.crossword-clues li button:hover,.crossword-clues li button.is-active { background: #dec38c; }
|
||||
.crossword-clues li b { color: #8a4f20; }
|
||||
.crossword-mobile-input { position: fixed; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.crossword-results-hero { color: #f8ead0; background: linear-gradient(135deg, #3b2819, #6d421f); box-shadow: 0 18px 45px rgba(15,9,4,.3); }
|
||||
.crossword-results-hero > div > div { background: rgba(255,244,220,.1); }
|
||||
.crossword-results-hero small,.crossword-results-hero strong { display: block; }
|
||||
.crossword-results-hero small { color: rgba(255,244,220,.6); font-size: .65rem; font-weight: 900; text-transform: uppercase; }
|
||||
.crossword-results-hero strong { margin-top: .2rem; font-size: 1.25rem; }
|
||||
.crossword-final-board { border: 1px solid #c8aa79; color: #392718; background: #f4e7c9; box-shadow: 0 12px 32px rgba(20,12,6,.18); }
|
||||
.crossword-final-legend { display: flex; flex-wrap: wrap; gap: .8rem; color: #6a5139; font-size: .72rem; font-weight: 800; }
|
||||
.crossword-final-legend span { display: inline-flex; align-items: center; gap: .35rem; }
|
||||
.crossword-final-legend i { width: .8rem; height: .8rem; border: 1px solid currentColor; border-radius: .15rem; }
|
||||
.crossword-final-legend i.is-correct { color: #356b3b; background: #bfe0b9; }
|
||||
.crossword-final-legend i.is-assisted { color: #8a631c; background: #f2d47e; }
|
||||
.crossword-final-legend i.is-incorrect { color: #923a30; background: #efb9af; }
|
||||
.crossword-final-viewport { overflow: auto; padding: 1rem; border-radius: 1rem; background: #2a1d13; box-shadow: inset 0 0 22px rgba(0,0,0,.55); }
|
||||
.crossword-final-grid { display: grid; width: max-content; min-width: 100%; place-content: center; }
|
||||
.crossword-final-cell { position: relative; display: grid; place-items: center; color: #25190f; border: 1px solid #62482f; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.crossword-final-cell.is-correct { background: #c7e6bf; box-shadow: inset 0 0 0 1px rgba(47,108,58,.35); }
|
||||
.crossword-final-cell.is-assisted { background: #f2d47e; box-shadow: inset 0 0 0 2px rgba(138,99,28,.42); }
|
||||
.crossword-final-cell.is-incorrect { background: #efbbb1; box-shadow: inset 0 0 0 2px rgba(145,47,39,.45); }
|
||||
.crossword-final-cell small { position: absolute; left: 2px; top: 1px; font-size: 7px; line-height: 1; }
|
||||
.crossword-final-cell strong { font-size: clamp(10px, 2vw, 15px); }
|
||||
.crossword-review { border: 1px solid #c8aa79; color: #392718; background: #f4e7c9; box-shadow: 0 8px 22px rgba(20,12,6,.15); }
|
||||
.crossword-review.is-incorrect { border-left: 6px solid #a63a2e; }
|
||||
.crossword-review.is-correct { border-left: 6px solid #4f7a4f; }
|
||||
.crossword-review.is-assisted { border-left: 6px solid #b58627; }
|
||||
.crossword-review.is-omitted { opacity: .75; border-left: 6px solid #8c765a; }
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.crossword-board-viewport { margin-inline: -.25rem; padding: .6rem; }
|
||||
.crossword-board { place-content: start; min-height: 24rem; }
|
||||
.crossword-clues { max-height: 28rem; }
|
||||
.crossword-hero::after { top: .75rem; right: .75rem; font-size: 2.75rem; }
|
||||
.crossword-final-viewport { margin-inline: -.25rem; padding: .65rem; }
|
||||
}
|
||||
|
||||
/* ── Markdown content styles ── */
|
||||
.markdown-content h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.5rem; color: var(--color-text-heading); }
|
||||
.markdown-content h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--color-text-heading); }
|
||||
.markdown-content h3 { font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--color-text-heading); }
|
||||
.markdown-content p { margin-bottom: 0.75rem; line-height: 1.7; }
|
||||
.markdown-content ul { list-style: disc inside; padding-left: 0; margin-bottom: 0.75rem; text-align: inherit; }
|
||||
.markdown-content ol { list-style: decimal inside; padding-left: 0; margin-bottom: 0.75rem; text-align: inherit; }
|
||||
.markdown-content li { margin-bottom: 0.25rem; line-height: 1.6; }
|
||||
.markdown-content strong { font-weight: 600; color: var(--color-text-heading); }
|
||||
.markdown-content code { background: var(--color-bg-surface-alt); padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.875rem; }
|
||||
.markdown-content table { border-collapse: collapse; width: 100%; margin-bottom: 0.75rem; }
|
||||
.markdown-content th { background: var(--color-bg-surface-alt); font-weight: 600; text-align: left; padding: 0.5rem 0.75rem; border: 1px solid var(--color-border-light); }
|
||||
.markdown-content td { padding: 0.5rem 0.75rem; border: 1px solid var(--color-border-light); }
|
||||
.markdown-content h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: .5rem; color: var(--color-text-heading); }
|
||||
.markdown-content h2 { font-size: 1.25rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }
|
||||
.markdown-content h3 { font-size: 1.125rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }
|
||||
.markdown-content p { margin-bottom: .75rem; line-height: 1.72; }
|
||||
.markdown-content ul { list-style: disc; padding-left: 1.35rem; margin-bottom: .75rem; }
|
||||
.markdown-content ol { list-style: decimal; padding-left: 1.35rem; margin-bottom: .75rem; }
|
||||
.markdown-content li { margin-bottom: .25rem; line-height: 1.65; }
|
||||
.markdown-content strong { font-weight: 700; color: var(--color-text-heading); }
|
||||
.markdown-content code { background: var(--color-bg-surface-alt); padding: .15rem .4rem; border-radius: .35rem; font-size: .875rem; }
|
||||
.markdown-content table { border-collapse: collapse; width: 100%; margin-bottom: .75rem; }
|
||||
.markdown-content th { background: var(--color-bg-surface-alt); font-weight: 650; text-align: left; padding: .6rem .8rem; border: 1px solid var(--color-border-light); }
|
||||
.markdown-content td { padding: .6rem .8rem; border: 1px solid var(--color-border-light); }
|
||||
.markdown-content *:last-child { margin-bottom: 0; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.app-page { padding-top: 1.25rem; padding-bottom: 6rem; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { Manrope, Newsreader } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
const manrope = Manrope({
|
||||
variable: "--font-manrope",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const newsreader = Newsreader({
|
||||
variable: "--font-newsreader",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Study App",
|
||||
description: "Practice exams and quizzes",
|
||||
title: "Study Desk",
|
||||
description: "A focused workspace for flashcards and practice quizzes",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
@ -19,13 +25,16 @@ export default function RootLayout({
|
|||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className={`${inter.variable} h-full`} suppressHydrationWarning>
|
||||
<html lang="en" className={`${manrope.variable} ${newsreader.variable} h-full`} data-scroll-behavior="smooth" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
id="theme-init"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
try {
|
||||
if (localStorage.theme === 'dark') {
|
||||
const savedTheme = localStorage.theme
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
|
|
@ -35,7 +44,7 @@ export default function RootLayout({
|
|||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-full flex flex-col font-sans antialiased">
|
||||
<body className="min-h-full flex flex-col font-sans antialiased" suppressHydrationWarning>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,166 +1,333 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
||||
|
||||
type LoginStage = "login" | "token" | "password" | "success";
|
||||
|
||||
async function postJson(path: string, body?: object) {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
const data: { error?: string } = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "Request failed");
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [stage, setStage] = useState<LoginStage>("login");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isSetup, setIsSetup] = useState<boolean | null>(null);
|
||||
const router = useRouter();
|
||||
const [setupRequired, setSetupRequired] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/auth/setup-status")
|
||||
.then((res) => res.json())
|
||||
.then((data) => setIsSetup(data.setupRequired))
|
||||
.catch(() => setIsSetup(false)); // fallback
|
||||
.then((response) => response.json())
|
||||
.then((data) => setSetupRequired(data.setupRequired))
|
||||
.catch(() => setSetupRequired(false));
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
async function run(action: () => Promise<void>) {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Login failed");
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = "/";
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
await action();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Network error. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isSetup === null) {
|
||||
function handleLogin(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
void run(async () => {
|
||||
await postJson("/api/auth/login", { password });
|
||||
window.location.href = "/";
|
||||
});
|
||||
}
|
||||
|
||||
function beginReset() {
|
||||
void run(async () => {
|
||||
await postJson("/api/auth/password-reset/request");
|
||||
setToken("");
|
||||
setStage("token");
|
||||
});
|
||||
}
|
||||
|
||||
function handleToken(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
void run(async () => {
|
||||
await postJson("/api/auth/password-reset/verify", { token: token.trim() });
|
||||
setPassword("");
|
||||
setConfirmPassword("");
|
||||
setStage("password");
|
||||
});
|
||||
}
|
||||
|
||||
function handlePasswordReset(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
void run(async () => {
|
||||
await postJson("/api/auth/password-reset/complete", {
|
||||
password,
|
||||
confirmPassword,
|
||||
});
|
||||
setToken("");
|
||||
setPassword("");
|
||||
setConfirmPassword("");
|
||||
setStage("success");
|
||||
});
|
||||
}
|
||||
|
||||
function returnToLogin() {
|
||||
setStage("login");
|
||||
setError("");
|
||||
setToken("");
|
||||
setPassword("");
|
||||
setConfirmPassword("");
|
||||
}
|
||||
|
||||
if (setupRequired === null) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="animate-pulse flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const title =
|
||||
stage === "token"
|
||||
? "Enter reset token"
|
||||
: stage === "password"
|
||||
? "Choose a new password"
|
||||
: stage === "success"
|
||||
? "Password updated"
|
||||
: setupRequired
|
||||
? "Welcome to Study Desk"
|
||||
: "Welcome back";
|
||||
|
||||
const description =
|
||||
stage === "token"
|
||||
? "Find the token in the Study Desk Docker logs. It expires after 15 minutes."
|
||||
: stage === "password"
|
||||
? "Use at least eight characters and enter the password twice."
|
||||
: stage === "success"
|
||||
? "Your old password and existing sessions are no longer valid."
|
||||
: setupRequired
|
||||
? "Please set your admin password for the first time."
|
||||
: "Open your notes, decks, and practice quizzes.";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm animate-fade-in">
|
||||
{/* Logo / Title area */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary/10 mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-text-heading">
|
||||
{isSetup ? "Welcome to Study App!" : "Study App"}
|
||||
</h1>
|
||||
<p className="text-text-muted mt-1 text-sm">
|
||||
{isSetup
|
||||
? "Please set your admin password for the first time."
|
||||
: "Enter your password to continue"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden p-4 sm:p-8">
|
||||
<div className="absolute inset-0 paper-grid opacity-70" />
|
||||
<div className="absolute -left-24 top-1/4 h-80 w-80 rounded-full bg-primary/15 blur-3xl" />
|
||||
<div className="absolute -right-24 bottom-0 h-72 w-72 rounded-full bg-accent/15 blur-3xl" />
|
||||
<div className="absolute right-4 top-4 z-10"><ThemeToggle /></div>
|
||||
|
||||
{/* Login card */}
|
||||
<div className="bg-bg-surface rounded-xl shadow-[var(--shadow-card)] border border-border-light p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-text-secondary mb-1.5"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
<main className="relative w-full max-w-md animate-fade-in">
|
||||
<header className="mb-7 text-center">
|
||||
<div className="mb-4 inline-grid h-14 w-14 place-items-center rounded-2xl bg-primary text-xl font-black text-white shadow-[0_12px_30px_color-mix(in_srgb,var(--theme-primary)_30%,transparent)]">S</div>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.2em] text-primary">Personal workspace</p>
|
||||
<h1 className="editorial-title mt-2 text-4xl text-text-heading">{title}</h1>
|
||||
<p className="mt-1 text-sm text-text-muted">{description}</p>
|
||||
</header>
|
||||
|
||||
<div className="rounded-[2rem] border border-border-light bg-bg-surface/90 p-6 shadow-[var(--shadow-modal)] backdrop-blur sm:p-8">
|
||||
{stage === "login" && (
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<PasswordField
|
||||
id="password"
|
||||
type="password"
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={isSetup ? "Create a new password" : "Enter your password"}
|
||||
onChange={setPassword}
|
||||
placeholder={setupRequired ? "Create a new password" : "Enter your password"}
|
||||
autoFocus
|
||||
className="w-full px-3.5 py-2.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-error bg-error-bg rounded-lg px-3 py-2.5">
|
||||
<svg
|
||||
className="w-4 h-4 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
<ErrorMessage message={error} />
|
||||
<PrimaryButton loading={loading} disabled={password.length < 8}>
|
||||
{setupRequired ? "Save Password & Login" : "Sign In"}
|
||||
</PrimaryButton>
|
||||
{!setupRequired && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={beginReset}
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg py-2 text-sm font-semibold text-primary hover:text-primary-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:opacity-50"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !password}
|
||||
className="w-full py-2.5 px-4 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
{isSetup ? "Saving..." : "Signing in..."}
|
||||
</span>
|
||||
) : isSetup ? (
|
||||
"Save Password & Login"
|
||||
) : (
|
||||
"Sign In"
|
||||
Forgot password?
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{stage === "token" && (
|
||||
<form onSubmit={handleToken} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="reset-token" className="mb-1.5 block text-sm font-medium text-text-secondary">Reset token</label>
|
||||
<input
|
||||
id="reset-token"
|
||||
value={token}
|
||||
onChange={(event) => setToken(event.target.value)}
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
className="min-h-12 w-full rounded-xl border border-border bg-bg-surface-alt/60 px-4 font-mono text-text-heading placeholder:text-text-muted focus:border-primary"
|
||||
placeholder="Paste the token from Docker logs"
|
||||
/>
|
||||
</div>
|
||||
<ErrorMessage message={error} />
|
||||
<PrimaryButton loading={loading} disabled={!token.trim()}>Validate Token</PrimaryButton>
|
||||
<SecondaryButton onClick={returnToLogin}>Back to sign in</SecondaryButton>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{stage === "password" && (
|
||||
<form onSubmit={handlePasswordReset} className="space-y-4">
|
||||
<div>
|
||||
<PasswordField
|
||||
id="new-password"
|
||||
label="New password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
describedBy="password-length-status"
|
||||
invalid={password.length < 8}
|
||||
autoFocus
|
||||
/>
|
||||
<ValidationMessage
|
||||
id="password-length-status"
|
||||
valid={password.length >= 8}
|
||||
validText="Password meets the length requirement."
|
||||
invalidText="Password must be at least 8 characters."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<PasswordField
|
||||
id="confirm-password"
|
||||
label="Confirm new password"
|
||||
value={confirmPassword}
|
||||
onChange={setConfirmPassword}
|
||||
describedBy={confirmPassword ? "password-match-status" : undefined}
|
||||
invalid={confirmPassword.length > 0 && confirmPassword !== password}
|
||||
/>
|
||||
{confirmPassword && (
|
||||
<ValidationMessage
|
||||
id="password-match-status"
|
||||
valid={confirmPassword === password}
|
||||
validText="Passwords match."
|
||||
invalidText="Passwords do not match."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ErrorMessage message={error} />
|
||||
<PrimaryButton
|
||||
loading={loading}
|
||||
disabled={password.length < 8 || confirmPassword !== password}
|
||||
>
|
||||
Set New Password
|
||||
</PrimaryButton>
|
||||
<SecondaryButton onClick={returnToLogin}>Cancel</SecondaryButton>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{stage === "success" && (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="rounded-xl bg-success-bg px-4 py-3 text-sm text-success" role="status">
|
||||
Your password has been reset successfully.
|
||||
</div>
|
||||
<PrimaryButton loading={false} onClick={returnToLogin}>Sign In</PrimaryButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
describedBy,
|
||||
invalid = false,
|
||||
autoFocus = false,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
describedBy?: string;
|
||||
invalid?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="mb-1.5 block text-sm font-medium text-text-secondary">{label}</label>
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete={id === "password" ? "current-password" : "new-password"}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
autoFocus={autoFocus}
|
||||
className="min-h-12 w-full rounded-xl border border-border bg-bg-surface-alt/60 px-4 text-text-heading placeholder:text-text-muted focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ValidationMessage({ id, valid, validText, invalidText }: {
|
||||
id: string;
|
||||
valid: boolean;
|
||||
validText: string;
|
||||
invalidText: string;
|
||||
}) {
|
||||
return (
|
||||
<p
|
||||
id={id}
|
||||
aria-live="polite"
|
||||
className={`mt-1.5 text-sm ${valid ? "text-success" : "text-error"}`}
|
||||
>
|
||||
{valid ? validText : invalidText}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorMessage({ message }: { message: string }) {
|
||||
if (!message) return null;
|
||||
return <div role="alert" className="rounded-lg bg-error-bg px-3 py-2.5 text-sm text-error">{message}</div>;
|
||||
}
|
||||
|
||||
function PrimaryButton({ loading, disabled = false, onClick, children }: {
|
||||
loading: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type={onClick ? "button" : "submit"}
|
||||
onClick={onClick}
|
||||
disabled={loading || disabled}
|
||||
className="min-h-12 w-full rounded-xl bg-primary px-4 font-bold text-white transition-colors hover:bg-primary-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Working..." : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SecondaryButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className="min-h-11 w-full rounded-xl border border-border px-4 font-semibold text-text-secondary hover:bg-bg-surface-alt focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary">
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import { useState, useEffect } from "react";
|
|||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { SharedGroupData } from "@/types/study";
|
||||
|
||||
export function SharedGroupViewer({ data }: { data: any }) {
|
||||
export function SharedGroupViewer({ data }: { data: SharedGroupData }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
|
|
@ -18,7 +19,7 @@ export function SharedGroupViewer({ data }: { data: any }) {
|
|||
if (typeof window === 'undefined') return;
|
||||
|
||||
const newProgress: Record<string, { currentIndex: number, total: number }> = {};
|
||||
items.forEach((item: any) => {
|
||||
items.forEach((item) => {
|
||||
const key = data.type === "DECK" ? `flashcard_progress_${item.id}` : `quiz_progress_${item.id}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
|
|
@ -71,7 +72,7 @@ export function SharedGroupViewer({ data }: { data: any }) {
|
|||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{items.map((item: any) => (
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="group flex flex-col bg-bg-surface border border-border-light rounded-xl shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300"
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@ import { usePathname } from "next/navigation";
|
|||
import { FlashcardViewer } from "@/components/flashcards/FlashcardViewer";
|
||||
import { QuizViewer } from "@/components/quizzes/QuizViewer";
|
||||
import { CardList } from "@/components/flashcards/CardList";
|
||||
import type { SharedStudyItem } from "@/types/study";
|
||||
|
||||
interface SharedViewerProps {
|
||||
type: string;
|
||||
data: any;
|
||||
type: "flashcards" | "quizzes";
|
||||
data: SharedStudyItem;
|
||||
groupMode?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -36,7 +37,7 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp
|
|||
|
||||
const [showTopics, setShowTopics] = useState(false);
|
||||
const categories = type === "quizzes" && data.questions
|
||||
? Array.from(new Set(data.questions.map((q: any) => q.category.toUpperCase()))).join(" · ")
|
||||
? Array.from(new Set(data.questions.map((q) => q.category.toUpperCase()))).join(" · ")
|
||||
: "";
|
||||
|
||||
return (
|
||||
|
|
@ -137,18 +138,20 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp
|
|||
view === "study" ? (
|
||||
<FlashcardViewer
|
||||
key={restartKey}
|
||||
cards={data.cards}
|
||||
cards={data.cards ?? []}
|
||||
deckId={data.id}
|
||||
isShared={true}
|
||||
/>
|
||||
) : (
|
||||
<CardList cards={data.cards} />
|
||||
<CardList cards={data.cards ?? []} />
|
||||
)
|
||||
) : (
|
||||
<QuizViewer
|
||||
key={restartKey}
|
||||
quiz={{
|
||||
...data,
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
questions: data.questions ?? [],
|
||||
progress: [], // Start fresh since it's shared
|
||||
}}
|
||||
retakeIds={null}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { getShareLink } from "@/services/shareService";
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
||||
import Link from "next/link";
|
||||
import { SharedViewer } from "./SharedViewer";
|
||||
import { SharedGroupViewer } from "./SharedGroupViewer";
|
||||
import type { SharedGroupData, SharedStudyItem } from "@/types/study";
|
||||
|
||||
type SharedContentType = "flashcards" | "quizzes";
|
||||
|
||||
interface SharedPageProps {
|
||||
params: Promise<{
|
||||
|
|
@ -27,6 +30,16 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
|||
notFound();
|
||||
}
|
||||
|
||||
// Older inherited-share URLs used a group token with a deck/quiz path.
|
||||
// They did not include an item ID, so preserve them by sending visitors to
|
||||
// the owning group where the intended item can be selected.
|
||||
if ((type === "flashcards" || type === "quizzes") && link.group) {
|
||||
if (link.group.class.slug !== classSlug) {
|
||||
notFound();
|
||||
}
|
||||
redirect(`/shared/${classSlug}/groups/${token}`);
|
||||
}
|
||||
|
||||
// Validate that the link matches the URL structure
|
||||
if (type === "flashcards" && !link.deck) notFound();
|
||||
if (type === "quizzes" && !link.quizSet) notFound();
|
||||
|
|
@ -38,17 +51,17 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
|||
notFound();
|
||||
}
|
||||
|
||||
let targetData: any = null;
|
||||
let targetType = type;
|
||||
let targetData: SharedStudyItem | SharedGroupData | null = null;
|
||||
let targetType: SharedContentType = type === "flashcards" ? "flashcards" : "quizzes";
|
||||
|
||||
if (type === "groups" && link.group) {
|
||||
if (itemId) {
|
||||
// Find the specific item inside the group
|
||||
if (link.group.type === "DECK") {
|
||||
targetData = link.group.decks.find(d => d.id === itemId);
|
||||
targetData = link.group.decks.find(d => d.id === itemId) ?? null;
|
||||
targetType = "flashcards";
|
||||
} else {
|
||||
targetData = link.group.quizSets.find(q => q.id === itemId);
|
||||
targetData = link.group.quizSets.find(q => q.id === itemId) ?? null;
|
||||
targetType = "quizzes";
|
||||
}
|
||||
if (!targetData) notFound();
|
||||
|
|
@ -59,16 +72,19 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
|||
targetData = type === "flashcards" ? link.deck : link.quizSet;
|
||||
}
|
||||
|
||||
if (!targetData) notFound();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-base flex flex-col">
|
||||
{/* Read-only Header */}
|
||||
<header className="bg-bg-surface border-b border-border-light shadow-sm sticky top-0 z-10">
|
||||
<header className="sticky top-0 z-10 border-b border-border-light bg-bg-surface/88 shadow-sm backdrop-blur-xl">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-14">
|
||||
<div className="flex items-center gap-2.5 text-text-heading font-semibold">
|
||||
<div className="flex items-center gap-2.5 font-semibold text-text-heading">
|
||||
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<span className="grid h-8 w-8 place-items-center rounded-xl bg-primary text-xs font-black text-white">S</span>
|
||||
Shared {type === "groups" ? "Group" : (type === "flashcards" ? "Deck" : "Quiz")}
|
||||
</div>
|
||||
|
||||
|
|
@ -92,13 +108,13 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
|||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mx-auto max-w-5xl px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
|
||||
{type === "groups" && !itemId ? (
|
||||
<SharedGroupViewer data={targetData} />
|
||||
<SharedGroupViewer data={targetData as SharedGroupData} />
|
||||
) : (
|
||||
<SharedViewer
|
||||
type={targetType}
|
||||
data={targetData}
|
||||
data={targetData as SharedStudyItem}
|
||||
groupMode={type === "groups"}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
188
src/components/activity/ActivityBanner.tsx
Normal file
188
src/components/activity/ActivityBanner.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface DailyActivity {
|
||||
date: string;
|
||||
flashcards: number;
|
||||
questions: number;
|
||||
arcade: number;
|
||||
total: number;
|
||||
level: 0 | 1 | 2 | 3 | 4;
|
||||
}
|
||||
|
||||
interface ActivitySummary {
|
||||
days: DailyActivity[];
|
||||
today: string;
|
||||
currentStreak: number;
|
||||
}
|
||||
|
||||
const levelClasses = [
|
||||
"bg-white/10",
|
||||
"bg-primary/45",
|
||||
"bg-primary",
|
||||
"bg-accent",
|
||||
"bg-[#fbbf24]",
|
||||
] as const;
|
||||
|
||||
export function ActivityBanner({ onNewClass }: { onNewClass: () => void }) {
|
||||
const [summary, setSummary] = useState<ActivitySummary | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/activity")
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error("Failed to load activity");
|
||||
return response.json();
|
||||
})
|
||||
.then(setSummary)
|
||||
.catch(() => setError(true));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="relative mb-10 overflow-hidden rounded-[2rem] bg-[#172033] px-5 py-7 text-white shadow-[var(--shadow-card)] sm:px-8 sm:py-9 dark:bg-[#111827]">
|
||||
<div className="absolute -right-16 -top-24 h-64 w-64 rounded-full bg-primary/70 blur-3xl" />
|
||||
<div className="absolute -bottom-28 right-1/3 h-52 w-52 rounded-full bg-accent/35 blur-3xl" />
|
||||
<div className="relative mb-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.22em] text-white/55">Personal library</p>
|
||||
<h1 className="editorial-title mt-1 text-3xl text-white sm:text-4xl">Study activity</h1>
|
||||
</div>
|
||||
<button onClick={onNewClass} className="inline-flex min-h-11 shrink-0 items-center justify-center gap-2 rounded-xl bg-white px-4 text-sm font-bold text-[#172033] shadow-lg transition-transform hover:-translate-y-0.5 sm:min-h-12 sm:px-5">
|
||||
<span className="text-xl leading-none">+</span> New class
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="relative rounded-xl border border-white/15 bg-white/8 px-4 py-5 text-sm text-white/70" role="alert">
|
||||
Study activity could not be loaded. Your library is still available below.
|
||||
</div>
|
||||
)}
|
||||
{!summary && !error && <div className="relative h-52 animate-subtle-pulse rounded-2xl bg-white/8" />}
|
||||
{summary && (
|
||||
<div className="relative grid gap-6 xl:grid-cols-[minmax(0,1fr)_10rem] xl:items-center">
|
||||
<div className="min-w-0">
|
||||
<ActivityHeatmap days={summary.days} today={summary.today} />
|
||||
</div>
|
||||
<StreakDisplay streak={summary.currentStreak} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityHeatmap({ days, today }: { days: DailyActivity[]; today: string }) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [activeDay, setActiveDay] = useState<DailyActivity | null>(null);
|
||||
const weeks = useMemo(
|
||||
() => Array.from({ length: 53 }, (_, index) => days.slice(index * 7, index * 7 + 7)),
|
||||
[days]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
if (container) container.scrollLeft = container.scrollWidth;
|
||||
}, [days]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative mb-2 h-12">
|
||||
<p className="absolute bottom-0 left-0 text-xs font-semibold text-white/55">Last 53 weeks · Arizona time</p>
|
||||
<div className="absolute right-0 top-0">
|
||||
{activeDay && <DayTooltip day={activeDay} />}
|
||||
</div>
|
||||
</div>
|
||||
<div ref={scrollRef} className="overflow-x-auto pb-2 [scrollbar-color:rgba(255,255,255,.25)_transparent]">
|
||||
<div className="grid w-max grid-cols-[2rem_auto] gap-2">
|
||||
<div className="pt-6 text-[10px] font-semibold leading-[15px] text-white/45" aria-hidden>
|
||||
<div className="h-[15px]" />
|
||||
<div>Mon</div>
|
||||
<div className="h-[15px]" />
|
||||
<div>Wed</div>
|
||||
<div className="h-[15px]" />
|
||||
<div>Fri</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex h-4 gap-[3px] text-[10px] font-semibold text-white/45" aria-hidden>
|
||||
{weeks.map((week, index) => (
|
||||
<div key={week[0]?.date ?? index} className="w-3">
|
||||
{getMonthLabel(week, weeks[index - 1])}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-[3px]" role="group" aria-label="Study activity over the last 53 weeks">
|
||||
{weeks.map((week, weekIndex) => (
|
||||
<div key={week[0]?.date ?? weekIndex} className="grid grid-rows-7 gap-[3px]">
|
||||
{week.map((day) => day.date > today ? (
|
||||
<span key={day.date} aria-hidden className="h-3 w-3" />
|
||||
) : (
|
||||
<button
|
||||
key={day.date}
|
||||
type="button"
|
||||
aria-label={getDayAriaLabel(day)}
|
||||
onMouseEnter={() => setActiveDay(day)}
|
||||
onMouseLeave={() => setActiveDay(null)}
|
||||
onFocus={() => setActiveDay(day)}
|
||||
onBlur={() => setActiveDay(null)}
|
||||
onClick={() => setActiveDay((current) => current?.date === day.date ? null : day)}
|
||||
className={`h-3 w-3 rounded-[3px] border border-white/8 ${levelClasses[day.level]} focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-end gap-1.5 text-[10px] font-semibold text-white/45" aria-label="Activity intensity from less to more">
|
||||
<span>Less</span>
|
||||
{levelClasses.map((className, index) => <span key={index} className={`h-3 w-3 rounded-[3px] border border-white/8 ${className}`} />)}
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DayTooltip({ day }: { day: DailyActivity }) {
|
||||
const formatted = new Date(`${day.date}T12:00:00Z`).toLocaleDateString("en-US", {
|
||||
timeZone: "America/Phoenix",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
return (
|
||||
<div role="tooltip" className="rounded-lg border border-white/15 bg-[#0d1422] px-3 py-2 text-right text-xs shadow-xl">
|
||||
<div className="font-bold text-white">{formatted} · {day.total} total</div>
|
||||
<div className="mt-0.5 text-white/60">{day.flashcards} flashcards · {day.questions} questions · {day.arcade} arcade groups</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StreakDisplay({ streak }: { streak: number }) {
|
||||
const size = 24 + Math.min(streak, 30) * 0.6;
|
||||
const color = streak === 0 ? "text-white/30" : streak < 7 ? "text-accent" : streak < 30 ? "text-[#ff7a36]" : "text-[#fbbf24]";
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-4 rounded-2xl border border-white/12 bg-white/7 px-5 py-4 xl:flex-col xl:gap-2 xl:text-center">
|
||||
<svg viewBox="0 0 24 24" aria-hidden className={`shrink-0 fill-current ${color}`} style={{ width: size, height: size }}>
|
||||
<path d="M13.5 2.1c.4 3.1-1.4 4.5-2.6 6.1-1 1.3-1.5 2.4-.8 4.1.5-1.4 1.5-2.3 2.5-3.2 1.8 1.6 3.4 3.6 3.4 6.3 0 2.5-1.7 4.6-4 4.6s-4-2-4-4.6c0-1.2.4-2.3 1-3.3-2.4 1.3-4 3.7-4 6.3 0 3.1 2.8 5.6 7 5.6s7-2.7 7-6.7c0-5.6-3.4-10.3-5.5-15.2Z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="text-3xl font-extrabold">{streak}</div>
|
||||
<div className="text-xs font-semibold text-white/55">day streak</div>
|
||||
<div className="mt-1 text-[10px] text-white/40">20 activities per day</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getMonthLabel(week: DailyActivity[], previousWeek?: DailyActivity[]) {
|
||||
const firstOfMonth = week.find((day) => Number(day.date.slice(8, 10)) <= 7);
|
||||
if (!firstOfMonth) return "";
|
||||
const month = firstOfMonth.date.slice(5, 7);
|
||||
if (previousWeek?.some((day) => day.date.slice(5, 7) === month)) return "";
|
||||
return new Date(`${firstOfMonth.date}T12:00:00Z`).toLocaleDateString("en-US", { month: "short", timeZone: "America/Phoenix" });
|
||||
}
|
||||
|
||||
function getDayAriaLabel(day: DailyActivity) {
|
||||
return `${day.date}: ${day.flashcards} flashcards, ${day.questions} questions, and ${day.arcade} arcade groups, ${day.total} total activities`;
|
||||
}
|
||||
38
src/components/arcade/ArcadeGameShell.tsx
Normal file
38
src/components/arcade/ArcadeGameShell.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function ArcadeGameShell({
|
||||
title,
|
||||
exitHref,
|
||||
elapsedSeconds,
|
||||
complete,
|
||||
worldClassName = "connections-world connections-stage",
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
exitHref: string;
|
||||
elapsedSeconds: number;
|
||||
complete: boolean;
|
||||
worldClassName?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
function exitGame() {
|
||||
if (!complete && !window.confirm("Leave this round? Your progress will not be saved.")) return;
|
||||
router.push(exitHref);
|
||||
}
|
||||
const minutes = Math.floor(elapsedSeconds / 60);
|
||||
const seconds = String(elapsedSeconds % 60).padStart(2, "0");
|
||||
return (
|
||||
<div className={`${worldClassName} mx-auto w-full max-w-6xl p-1 pb-16 sm:p-3`}>
|
||||
<header className="arcade-game-topbar connections-topbar mb-5 flex items-center justify-between gap-4 rounded-2xl border border-border-light bg-bg-surface/85 px-4 py-3 shadow-sm">
|
||||
<button onClick={exitGame} className="min-h-10 rounded-xl px-3 text-sm font-bold text-text-muted hover:bg-bg-surface-alt hover:text-text-heading">← Exit</button>
|
||||
<h1 className="truncate text-center text-lg font-extrabold text-text-heading">{title}</h1>
|
||||
<div className="min-w-16 text-right font-mono text-sm font-bold text-text-muted" aria-label={`${elapsedSeconds} seconds elapsed`}>{minutes}:{seconds}</div>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
src/components/arcade/ArcadeImportModal.tsx
Normal file
127
src/components/arcade/ArcadeImportModal.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { GenerateTab } from "@/components/import/GenerateTab";
|
||||
import type { ArcadeGameKey, ArcadeImportBatchPreview } from "@/types/arcade";
|
||||
|
||||
export function ArcadeImportModal({
|
||||
classId,
|
||||
gameType,
|
||||
onClose,
|
||||
onImported,
|
||||
}: {
|
||||
classId: string;
|
||||
gameType: ArcadeGameKey;
|
||||
onClose: () => void;
|
||||
onImported: () => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"generate" | "import">("import");
|
||||
const [rawJson, setRawJson] = useState("");
|
||||
const [names, setNames] = useState<string[]>([]);
|
||||
const [preview, setPreview] = useState<ArcadeImportBatchPreview | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [details, setDetails] = useState<string[]>([]);
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function previewImport() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setDetails([]);
|
||||
setPreview(null);
|
||||
try {
|
||||
const response = await fetch("/api/arcade/import/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ gameType, rawJson }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
setError(data.error ?? "Import preview failed");
|
||||
setDetails(data.details ?? []);
|
||||
return;
|
||||
}
|
||||
setPreview(data);
|
||||
setNames(data.packs.map((pack: { name: string }) => pack.name));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveImport() {
|
||||
if (!preview || names.some((name) => !name.trim())) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/arcade/packs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
classId,
|
||||
gameType,
|
||||
rawJson,
|
||||
...(gameType === "connections" ? { names: names.map((name) => name.trim()) } : { name: names[0]?.trim() }),
|
||||
warningsAcknowledged: acknowledged,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
setError(data.error ?? "Import failed");
|
||||
setDetails(data.details ?? []);
|
||||
return;
|
||||
}
|
||||
onImported();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center sm:p-4">
|
||||
<button className="absolute inset-0 bg-black/45 backdrop-blur-sm" onClick={onClose} aria-label="Close import dialog" />
|
||||
<section role="dialog" aria-modal="true" aria-labelledby="arcade-import-title" className={`${gameType === "connections" ? "connections-world connections-import" : "crossword-world crossword-import"} relative flex max-h-[92vh] w-full max-w-2xl flex-col rounded-t-3xl border border-border-light bg-bg-surface shadow-[var(--shadow-modal)] sm:rounded-3xl`}>
|
||||
<div className="flex items-center justify-between px-6 pt-5">
|
||||
<h2 id="arcade-import-title" className="editorial-title text-2xl text-text-heading">Import {gameType === "connections" ? "Connections" : "Crossword"} pack</h2>
|
||||
<button onClick={onClose} className="grid h-10 w-10 place-items-center rounded-xl text-xl text-text-muted hover:bg-bg-surface-alt" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div className="flex gap-1 border-b border-border-light px-6 pt-4">
|
||||
{(["generate", "import"] as const).map((item) => (
|
||||
<button key={item} onClick={() => setTab(item)} className={`border-b-2 px-4 py-3 text-sm font-bold capitalize ${tab === item ? "border-primary text-primary" : "border-transparent text-text-muted"}`}>{item}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="overflow-y-auto p-6">
|
||||
{tab === "generate" ? <GenerateTab importType={gameType} /> : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-text-secondary">{gameType === "connections" ? "Paste one pack object or an array of up to ten packs." : "Paste one Crossword pack containing exactly 80 entries."} Every board is validated before anything is saved.</p>
|
||||
<textarea value={rawJson} onChange={(event) => { setRawJson(event.target.value); setPreview(null); setError(""); }} rows={11} placeholder={`Paste ${gameType === "connections" ? "Connections" : "Crossword"} JSON here…`} className="w-full resize-y rounded-xl border border-border bg-bg-surface-alt/60 px-4 py-3 font-mono text-sm text-text-heading focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" />
|
||||
{!preview && <button onClick={previewImport} disabled={!rawJson.trim() || busy} className="w-full min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white disabled:opacity-45">{busy ? "Checking…" : "Preview pack(s)"}</button>}
|
||||
{error && <div role="alert" className="rounded-xl border border-error/20 bg-error-bg p-4 text-sm text-error"><p className="font-bold">{error}</p>{details.length > 0 && <ul className="mt-2 list-disc space-y-1 pl-5">{details.map((detail) => <li key={detail}>{detail}</li>)}</ul>}</div>}
|
||||
{preview && (
|
||||
<div className="space-y-4 rounded-2xl border border-primary/15 bg-bg-callout p-4">
|
||||
<div className="flex items-center justify-between"><p className="font-extrabold text-text-heading">{preview.count} {preview.count === 1 ? "pack" : "packs"} ready</p>{preview.wasRepaired && <span className="text-xs font-bold text-primary">JSON syntax repaired</span>}</div>
|
||||
<div className="space-y-3">
|
||||
{preview.packs.map((pack, index) => (
|
||||
<article key={`${pack.name}-${index}`} className="rounded-xl border border-border-light bg-bg-surface p-3">
|
||||
<label className="mb-1 block text-xs font-bold uppercase tracking-wide text-text-muted">Pack {index + 1} name</label>
|
||||
<input value={names[index] ?? ""} onChange={(event) => setNames((current) => current.map((name, nameIndex) => nameIndex === index ? event.target.value : name))} className="w-full rounded-lg border border-border bg-bg-surface-alt/50 px-3 py-2 font-bold text-text-heading" />
|
||||
{gameType === "connections" ? <>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">{pack.categories.map((category) => <span key={category} className="rounded-full bg-bg-surface-alt px-2 py-1 text-[11px] font-bold text-text-secondary">{category}</span>)}</div>
|
||||
<p className="mt-2 text-xs text-text-muted">16 tiles · 4 groups</p>
|
||||
</> : <>
|
||||
<p className="mt-2 text-xs text-text-muted">80 entries · four board sizes</p>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-xs text-text-secondary">{pack.layoutPreviews?.map((layout) => <span key={layout.size} className="rounded-lg bg-bg-surface-alt px-2 py-1.5 capitalize">{layout.size}: {layout.placedCount}/{layout.targetCount}</span>)}</div>
|
||||
</>}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{preview.packs.some((pack) => pack.warnings.length > 0) && <div className="rounded-xl border border-amber-400/30 bg-amber-400/10 p-3 text-sm text-text-secondary"><p className="font-bold text-text-heading">Review warnings</p><ul className="mt-1 list-disc space-y-1 pl-5">{preview.packs.flatMap((pack, index) => pack.warnings.map((warning) => `Pack ${index + 1}: ${warning}`)).map((warning) => <li key={warning}>{warning}</li>)}</ul><label className="mt-3 flex items-start gap-2"><input type="checkbox" checked={acknowledged} onChange={(event) => setAcknowledged(event.target.checked)} className="mt-1" /><span>I reviewed these warnings and want to import the packs.</span></label></div>}
|
||||
<button onClick={saveImport} disabled={busy || names.some((name) => !name.trim()) || (preview.packs.some((pack) => pack.warnings.length > 0) && !acknowledged)} className="w-full min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white disabled:opacity-45">{busy ? "Importing…" : `Import ${preview.count} ${preview.count === 1 ? "pack" : "packs"}`}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
237
src/components/arcade/ConnectionsGame.tsx
Normal file
237
src/components/arcade/ConnectionsGame.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { ArcadeGameShell } from "@/components/arcade/ArcadeGameShell";
|
||||
import { deterministicShuffle, replayConnectionsAttempt } from "@/lib/arcade/connectionsEngine";
|
||||
import type {
|
||||
ArcadeRoundResult,
|
||||
ArcadeSessionSettings,
|
||||
ConnectionsSubmission,
|
||||
NormalizedConnectionsPack,
|
||||
} from "@/types/arcade";
|
||||
|
||||
export function ConnectionsGame({
|
||||
seed,
|
||||
packId,
|
||||
packName,
|
||||
classSlug,
|
||||
pack,
|
||||
settings,
|
||||
}: {
|
||||
seed: string;
|
||||
packId: string;
|
||||
packName: string;
|
||||
classSlug: string;
|
||||
pack: NormalizedConnectionsPack;
|
||||
settings: ArcadeSessionSettings;
|
||||
}) {
|
||||
const allItems = useMemo(() => pack.content.flatMap((group) => group.items), [pack]);
|
||||
const itemById = useMemo(() => new Map(allItems.map((item) => [item.id, item])), [allItems]);
|
||||
const [tileOrder, setTileOrder] = useState(() => deterministicShuffle(allItems.map((item) => item.id), seed));
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [submissions, setSubmissions] = useState<ConnectionsSubmission[]>([]);
|
||||
const [feedback, setFeedback] = useState("Select four related tiles.");
|
||||
const [result, setResult] = useState<ArcadeRoundResult | null>(null);
|
||||
const [finishing, setFinishing] = useState<ArcadeRoundResult | null>(null);
|
||||
const [feedbackKind, setFeedbackKind] = useState<"idle" | "correct" | "wrong">("idle");
|
||||
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
const shuffleCount = useRef(0);
|
||||
const startedAt = useRef(0);
|
||||
const replay = useMemo(
|
||||
() => replayConnectionsAttempt(pack, submissions, settings, elapsedSeconds),
|
||||
[elapsedSeconds, pack, settings, submissions]
|
||||
);
|
||||
const solvedGroupIds = useMemo(
|
||||
() => new Set(replay.result.groups.filter((group) => group.solved).map((group) => group.groupId)),
|
||||
[replay.result.groups]
|
||||
);
|
||||
const solvedItemIds = useMemo(
|
||||
() => new Set(pack.content.filter((group) => solvedGroupIds.has(group.id)).flatMap((group) => group.items.map((item) => item.id))),
|
||||
[pack, solvedGroupIds]
|
||||
);
|
||||
const unresolvedOrder = tileOrder.filter((id) => !solvedItemIds.has(id));
|
||||
|
||||
useEffect(() => {
|
||||
if (startedAt.current === 0) startedAt.current = Date.now();
|
||||
if (result) return;
|
||||
const interval = window.setInterval(() => setElapsedSeconds(Math.floor((Date.now() - startedAt.current) / 1000)), 1000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [result]);
|
||||
|
||||
function toggleTile(id: string) {
|
||||
if (result || solvedItemIds.has(id)) return;
|
||||
setSelectedIds((current) => current.includes(id) ? current.filter((itemId) => itemId !== id) : current.length < 4 ? [...current, id] : current);
|
||||
}
|
||||
|
||||
async function persistAttempt(nextSubmissions: ConnectionsSubmission[], roundResult: ArcadeRoundResult) {
|
||||
setSaveState("saving");
|
||||
const response = await fetch(`/api/arcade/packs/${packId}/attempts`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mode: "CLASSIC",
|
||||
durationSeconds: roundResult.durationSeconds,
|
||||
seed,
|
||||
settings,
|
||||
submissions: nextSubmissions,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
setSaveState("error");
|
||||
return;
|
||||
}
|
||||
await response.json();
|
||||
setSaveState("saved");
|
||||
}
|
||||
|
||||
function submitSelection() {
|
||||
if (selectedIds.length !== 4 || result) return;
|
||||
const previousSolved = solvedGroupIds.size;
|
||||
const nextSubmissions = [...submissions, { itemIds: selectedIds, elapsedMs: Date.now() - startedAt.current }];
|
||||
const duration = Math.floor((Date.now() - startedAt.current) / 1000);
|
||||
const nextReplay = replayConnectionsAttempt(pack, nextSubmissions, settings, duration);
|
||||
setSubmissions(nextSubmissions);
|
||||
setSelectedIds([]);
|
||||
if (nextReplay.result.groups.filter((group) => group.solved).length > previousSolved) {
|
||||
setFeedback("Correct group found.");
|
||||
setFeedbackKind("correct");
|
||||
} else if (nextReplay.result.hintsUsed > replay.result.hintsUsed) {
|
||||
setFeedback("One away — three of those tiles belong together.");
|
||||
setFeedbackKind("wrong");
|
||||
} else {
|
||||
setFeedback("Not a group. Try another combination.");
|
||||
setFeedbackKind("wrong");
|
||||
}
|
||||
if (nextReplay.complete) {
|
||||
setElapsedSeconds(duration);
|
||||
setFinishing(nextReplay.result);
|
||||
window.setTimeout(() => {
|
||||
setResult(nextReplay.result);
|
||||
setFinishing(null);
|
||||
}, nextReplay.result.outcome === "WON" ? 1700 : 850);
|
||||
void persistAttempt(nextSubmissions, nextReplay.result);
|
||||
}
|
||||
}
|
||||
|
||||
function shuffleTiles() {
|
||||
shuffleCount.current += 1;
|
||||
const shuffled = deterministicShuffle(unresolvedOrder, `${seed}-shuffle-${shuffleCount.current}`);
|
||||
setTileOrder([...tileOrder.filter((id) => solvedItemIds.has(id)), ...shuffled]);
|
||||
setSelectedIds([]);
|
||||
setFeedback("Unsolved tiles shuffled.");
|
||||
setFeedbackKind("idle");
|
||||
}
|
||||
|
||||
function handleTileKey(event: React.KeyboardEvent<HTMLButtonElement>, id: string) {
|
||||
const moves: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -4, ArrowDown: 4 };
|
||||
const move = moves[event.key];
|
||||
if (!move) return;
|
||||
event.preventDefault();
|
||||
const index = unresolvedOrder.indexOf(id);
|
||||
const next = unresolvedOrder[(index + move + unresolvedOrder.length) % unresolvedOrder.length];
|
||||
document.querySelector<HTMLButtonElement>(`[data-arcade-tile="${next}"]`)?.focus();
|
||||
}
|
||||
|
||||
if (result) {
|
||||
return (
|
||||
<ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/connections`} elapsedSeconds={elapsedSeconds} complete>
|
||||
<ConnectionsResults result={result} itemById={itemById} saveState={saveState} onReplay={() => window.location.reload()} exitHref={`/${classSlug}/arcade/connections`} />
|
||||
</ArcadeGameShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (finishing) {
|
||||
return (
|
||||
<ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/connections`} elapsedSeconds={elapsedSeconds} complete>
|
||||
<CompletionTransition result={finishing} />
|
||||
</ArcadeGameShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/connections`} elapsedSeconds={elapsedSeconds} complete={false}>
|
||||
<div className="connections-status mb-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-border-light bg-bg-surface p-4">
|
||||
<div><p className="text-xs font-bold uppercase tracking-wide text-text-muted">Mistakes remaining</p><div className="mt-1 flex gap-1.5" aria-label={`${settings.allowedMistakes - replay.result.mistakes} mistakes remaining`}>{Array.from({ length: settings.allowedMistakes }, (_, index) => <span key={index} className={`h-3 w-3 rounded-full border ${index < settings.allowedMistakes - replay.result.mistakes ? "border-primary bg-primary" : "border-border bg-bg-surface-alt"}`} />)}</div></div>
|
||||
<p className="text-sm font-bold text-text-secondary" aria-live="polite">{feedback}</p>
|
||||
</div>
|
||||
|
||||
{pack.content.filter((group) => solvedGroupIds.has(group.id)).map((group, index) => (
|
||||
<div key={group.id} className={`connections-group connections-group-${index % 4} mb-2 rounded-2xl p-4 text-center`}>
|
||||
<h2 className="font-extrabold uppercase tracking-wide text-text-heading">{group.category}</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-text-secondary">{group.items.map((item) => item.text).join(" · ")}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div key={`board-${submissions.length}`} className={`connections-board grid grid-cols-4 gap-2 sm:gap-3 ${feedbackKind === "wrong" ? "animate-connections-shake" : ""}`} role="group" aria-label="Connections tiles">
|
||||
{unresolvedOrder.map((id) => {
|
||||
const item = itemById.get(id);
|
||||
if (!item) return null;
|
||||
const selected = selectedIds.includes(id);
|
||||
return <button key={id} data-arcade-tile={id} aria-pressed={selected} onKeyDown={(event) => handleTileKey(event, id)} onClick={() => toggleTile(id)} className={`connections-tile min-h-20 break-words rounded-xl px-1.5 py-2 text-[11px] font-extrabold leading-tight sm:min-h-24 sm:px-3 sm:text-sm ${selected ? "is-selected" : ""}`}>{item.text}</button>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 sm:flex sm:justify-center">
|
||||
<button onClick={shuffleTiles} className="connections-secondary-button min-h-11 rounded-xl px-5 text-sm font-bold">Shuffle</button>
|
||||
<button onClick={() => setSelectedIds([])} disabled={selectedIds.length === 0} className="connections-secondary-button min-h-11 rounded-xl px-5 text-sm font-bold disabled:opacity-40">Clear</button>
|
||||
<button onClick={submitSelection} disabled={selectedIds.length !== 4} className="connections-primary-button col-span-2 min-h-11 rounded-xl px-7 text-sm font-extrabold disabled:opacity-40 sm:col-span-1">Submit group ({selectedIds.length}/4)</button>
|
||||
</div>
|
||||
</ArcadeGameShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionsResults({
|
||||
result,
|
||||
itemById,
|
||||
saveState,
|
||||
onReplay,
|
||||
exitHref,
|
||||
}: {
|
||||
result: ArcadeRoundResult;
|
||||
itemById: Map<string, { id: string; text: string }>;
|
||||
saveState: "idle" | "saving" | "saved" | "error";
|
||||
onReplay: () => void;
|
||||
exitHref: string;
|
||||
}) {
|
||||
const orderedGroups = [...result.groups].sort((a, b) => Number(a.solved) - Number(b.solved));
|
||||
return (
|
||||
<div className="animate-connections-results-in">
|
||||
<section className="connections-results-hero rounded-3xl p-6 text-white sm:p-8">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.18em] text-white/55">Round complete</p>
|
||||
<h2 className="editorial-title mt-1 text-4xl text-white">{result.outcome === "WON" ? "Board cleared" : "Groups revealed"}</h2>
|
||||
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">{[["Score", `${result.score}/${result.maxScore}`], ["Accuracy", `${Math.round(result.accuracy * 100)}%`], ["Mistakes", result.mistakes], ["Time", `${result.durationSeconds}s`]].map(([label, value]) => <div key={label} className="rounded-2xl bg-white/8 p-3"><p className="text-xs font-bold uppercase text-white/45">{label}</p><p className="mt-1 text-xl font-extrabold">{value}</p></div>)}</div>
|
||||
<p className="mt-4 text-xs text-white/55">{saveState === "saving" ? "Saving attempt…" : saveState === "error" ? "Attempt could not be saved." : "Attempt saved."}</p>
|
||||
</section>
|
||||
<div className="mt-5 space-y-3">{orderedGroups.map((group, index) => <article key={group.groupId} className={`connections-review-card connections-group-${index % 4} rounded-2xl p-4`}><p className="text-xs font-extrabold uppercase tracking-wide opacity-60">{group.solved ? "Solved" : "Revealed"}</p><h3 className="mt-1 text-lg font-extrabold">{group.category}</h3><p className="mt-1 text-sm font-semibold opacity-75">{group.items.join(" · ")}</p><p className="mt-3 text-sm leading-6 opacity-80">{group.explanation}</p></article>)}</div>
|
||||
{result.incorrectSelections.length > 0 && <section className="mt-5 rounded-2xl border border-border-light bg-bg-surface p-4"><h3 className="font-extrabold text-text-heading">Incorrect selections</h3><ul className="mt-2 space-y-1 text-sm text-text-secondary">{result.incorrectSelections.map((selection, index) => <li key={`${selection.join("-")}-${index}`}>{selection.map((id) => itemById.get(id)?.text ?? id).join(" · ")}</li>)}</ul></section>}
|
||||
<div className="mt-6 grid grid-cols-2 gap-3"><button onClick={onReplay} className="min-h-12 rounded-xl bg-primary px-5 text-sm font-extrabold text-white">Play again</button><a href={exitHref} className="flex min-h-12 items-center justify-center rounded-xl border border-border bg-bg-surface px-5 text-sm font-extrabold text-text-heading">Back to packs</a></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompletionTransition({ result }: { result: ArcadeRoundResult }) {
|
||||
const won = result.outcome === "WON";
|
||||
return (
|
||||
<div className={`connections-completion ${won ? "is-win" : "is-loss"}`}>
|
||||
{won && <ConfettiBurst />}
|
||||
<div className="connections-completion-mark" aria-hidden>{won ? "✓" : "!"}</div>
|
||||
<p>{won ? "Perfect connection" : "Round complete"}</p>
|
||||
<h2>{won ? "Board cleared!" : "Groups revealed"}</h2>
|
||||
<span>{won ? `${result.score} points` : "Let’s review the board"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfettiBurst() {
|
||||
const colors = ["#ffd166", "#ef476f", "#06d6a0", "#4cc9f0", "#a78bfa"];
|
||||
return <div className="connections-confetti" aria-hidden>{Array.from({ length: 52 }, (_, index) => {
|
||||
const style = {
|
||||
"--confetti-x": `${(index * 37) % 100}%`,
|
||||
"--confetti-delay": `${(index % 13) * 0.035}s`,
|
||||
"--confetti-spin": `${180 + (index % 7) * 80}deg`,
|
||||
"--confetti-color": colors[index % colors.length],
|
||||
} as CSSProperties;
|
||||
return <i key={index} style={style} />;
|
||||
})}</div>;
|
||||
}
|
||||
123
src/components/arcade/ConnectionsHub.tsx
Normal file
123
src/components/arcade/ConnectionsHub.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArcadeImportModal } from "@/components/arcade/ArcadeImportModal";
|
||||
import type { ArcadeAttemptSummary, ArcadePackSummary } from "@/types/arcade";
|
||||
|
||||
export function ConnectionsHub({
|
||||
classId,
|
||||
classSlug,
|
||||
initialPacks,
|
||||
}: {
|
||||
classId: string;
|
||||
classSlug: string;
|
||||
initialPacks: ArcadePackSummary[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [selectedId, setSelectedId] = useState(initialPacks[0]?.id ?? "");
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [allowedMistakes, setAllowedMistakes] = useState(initialPacks[0]?.defaultAllowedMistakes ?? 4);
|
||||
const [oneAwayFeedback, setOneAwayFeedback] = useState(true);
|
||||
const [attempts, setAttempts] = useState<ArcadeAttemptSummary[]>([]);
|
||||
const [attemptsLoading, setAttemptsLoading] = useState(initialPacks.length > 0);
|
||||
const effectiveSelectedId = initialPacks.some((pack) => pack.id === selectedId)
|
||||
? selectedId
|
||||
: initialPacks[0]?.id ?? "";
|
||||
const selected = useMemo(() => initialPacks.find((pack) => pack.id === effectiveSelectedId) ?? null, [effectiveSelectedId, initialPacks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
fetch(`/api/arcade/packs/${selected.id}/attempts`)
|
||||
.then((response) => response.ok ? response.json() : [])
|
||||
.then(setAttempts)
|
||||
.finally(() => setAttemptsLoading(false));
|
||||
}, [selected]);
|
||||
|
||||
function selectPack(pack: ArcadePackSummary) {
|
||||
setSelectedId(pack.id);
|
||||
setAllowedMistakes(pack.defaultAllowedMistakes);
|
||||
setAttempts([]);
|
||||
setAttemptsLoading(true);
|
||||
}
|
||||
|
||||
async function renamePack(pack: ArcadePackSummary) {
|
||||
const name = window.prompt("Rename Connections pack", pack.name)?.trim();
|
||||
if (!name || name === pack.name) return;
|
||||
await fetch(`/api/arcade/packs/${pack.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function deletePack(pack: ArcadePackSummary) {
|
||||
if (!window.confirm(`Delete “${pack.name}” and its attempt history?`)) return;
|
||||
await fetch(`/api/arcade/packs/${pack.id}`, { method: "DELETE" });
|
||||
if (selectedId === pack.id) setSelectedId("");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const playHref = selected
|
||||
? `/${classSlug}/arcade/connections/${selected.id}/play?mistakes=${allowedMistakes}&oneAway=${oneAwayFeedback ? "1" : "0"}`
|
||||
: "#";
|
||||
|
||||
return (
|
||||
<div className="connections-world connections-hub p-1 pb-20 sm:p-3">
|
||||
<div className="connections-hub-hero mb-7 flex flex-col gap-4 rounded-3xl p-5 sm:flex-row sm:items-end sm:justify-between sm:p-7">
|
||||
<div>
|
||||
<Link href={`/${classSlug}/arcade`} className="mb-3 inline-flex min-h-10 items-center text-sm font-bold text-text-muted hover:text-primary">← Back to Arcade</Link>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.18em] text-primary">The pattern room</p>
|
||||
<h2 className="editorial-title mt-1 text-4xl text-text-heading">Connections</h2>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-text-secondary">Choose a pack, tune the mistake limit, and find the four hidden groups.</p>
|
||||
</div>
|
||||
<button onClick={() => setShowImport(true)} className="connections-primary-button min-h-12 rounded-xl px-5 text-sm font-bold">Import pack</button>
|
||||
</div>
|
||||
|
||||
{initialPacks.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-border bg-bg-surface px-6 py-16 text-center">
|
||||
<div className="mx-auto mb-4 grid w-fit grid-cols-2 gap-1.5" aria-hidden>{["bg-amber-400", "bg-emerald-500", "bg-sky-500", "bg-violet-500"].map((color) => <span key={color} className={`h-8 w-8 rounded-lg ${color}`} />)}</div>
|
||||
<h3 className="editorial-title text-2xl text-text-heading">Import your first board</h3>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-text-secondary">Connections uses purpose-built packs with four groups of four terms.</p>
|
||||
<button onClick={() => setShowImport(true)} className="mt-6 min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white">Import Connections JSON</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.15fr)_minmax(20rem,.85fr)]">
|
||||
<section>
|
||||
<h3 className="mb-3 text-sm font-extrabold uppercase tracking-[0.14em] text-text-muted">Study packs</h3>
|
||||
<div className="space-y-3">
|
||||
{initialPacks.map((pack) => {
|
||||
const active = pack.id === effectiveSelectedId;
|
||||
return (
|
||||
<article key={pack.id} className={`connections-pack-card rounded-2xl border p-4 transition-all ${active ? "is-active" : ""}`}>
|
||||
<button onClick={() => selectPack(pack)} className="w-full text-left">
|
||||
<div className="flex items-start justify-between gap-3"><div><h4 className="text-lg font-extrabold text-text-heading">{pack.name}</h4>{pack.description && <p className="mt-1 text-sm leading-5 text-text-secondary">{pack.description}</p>}</div><span className={`mt-1 h-4 w-4 shrink-0 rounded-full border-4 ${active ? "border-primary bg-white" : "border-border bg-bg-surface"}`} aria-hidden /></div>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs font-bold text-text-muted"><span className="rounded-full bg-bg-surface-alt px-2.5 py-1">16 tiles</span><span className="rounded-full bg-bg-surface-alt px-2.5 py-1">Best {pack.bestScore ?? "—"}/400</span><span className="rounded-full bg-bg-surface-alt px-2.5 py-1">Latest {pack.latestAttempt?.score ?? "—"}</span></div>
|
||||
</button>
|
||||
<div className="mt-3 flex justify-end gap-2 border-t border-border-light pt-3"><button onClick={() => renamePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-bg-surface-alt hover:text-text-heading">Rename</button><button onClick={() => deletePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-error-bg hover:text-error">Delete</button></div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="connections-setup h-fit rounded-3xl border border-border-light bg-bg-surface p-5 lg:sticky lg:top-6">
|
||||
<h3 className="editorial-title text-2xl text-text-heading">Round setup</h3>
|
||||
{selected ? <>
|
||||
<p className="mt-1 text-sm font-bold text-primary">{selected.name}</p>
|
||||
<label className="mt-5 block text-sm font-bold text-text-heading">Allowed mistakes <span className="text-primary">{allowedMistakes}</span></label>
|
||||
<input type="range" min={1} max={8} value={allowedMistakes} onChange={(event) => setAllowedMistakes(Number(event.target.value))} className="mt-2 w-full accent-primary" />
|
||||
<label className="mt-5 flex items-start gap-3 rounded-xl bg-bg-surface-alt p-3 text-sm text-text-secondary"><input type="checkbox" checked={oneAwayFeedback} onChange={(event) => setOneAwayFeedback(event.target.checked)} className="mt-1" /><span><strong className="block text-text-heading">One-away feedback</strong>Tell me when three selected tiles belong together.</span></label>
|
||||
<div className="mt-5 rounded-xl border border-border-light p-3 text-sm text-text-secondary"><div className="flex justify-between"><span>Board</span><strong className="text-text-heading">4 × 4</strong></div><div className="mt-2 flex justify-between"><span>Possible score</span><strong className="text-text-heading">400</strong></div></div>
|
||||
<Link href={playHref} className="connections-primary-button mt-5 flex min-h-12 items-center justify-center rounded-xl px-5 text-sm font-extrabold">Enter the board</Link>
|
||||
<div className="mt-7 border-t border-border-light pt-5"><h4 className="text-sm font-extrabold uppercase tracking-wide text-text-muted">Recent attempts</h4>{attemptsLoading ? <p className="mt-3 text-sm text-text-muted">Loading history…</p> : attempts.length === 0 ? <p className="mt-3 text-sm text-text-muted">No attempts yet.</p> : <div className="mt-3 space-y-2">{attempts.slice(0, 5).map((attempt) => <div key={attempt.id} className="flex items-center justify-between rounded-xl bg-bg-surface-alt px-3 py-2 text-sm"><span className="font-bold text-text-heading">{attempt.score}/400</span><span className="text-text-muted">{Math.round(attempt.accuracy * 100)}% · {attempt.durationSeconds}s</span></div>)}</div>}</div>
|
||||
</> : <p className="mt-3 text-sm text-text-muted">Select a pack to configure the round.</p>}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
{showImport && <ArcadeImportModal classId={classId} gameType="connections" onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); router.refresh(); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
269
src/components/arcade/CrosswordGame.tsx
Normal file
269
src/components/arcade/CrosswordGame.tsx
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { ArcadeGameShell } from "@/components/arcade/ArcadeGameShell";
|
||||
import type {
|
||||
CrosswordAction,
|
||||
CrosswordLayout,
|
||||
CrosswordPlacedEntry,
|
||||
CrosswordRoundResult,
|
||||
CrosswordSessionSettings,
|
||||
NormalizedCrosswordPack,
|
||||
} from "@/types/arcade";
|
||||
|
||||
function entryValue(entry: CrosswordPlacedEntry, answers: Record<string, string>) {
|
||||
return entry.cellKeys.map((cellKey) => answers[cellKey] ?? "").join("");
|
||||
}
|
||||
|
||||
export function CrosswordGame({ seed, packId, packName, classSlug, layout, settings }: {
|
||||
seed: string;
|
||||
packId: string;
|
||||
packName: string;
|
||||
classSlug: string;
|
||||
pack: NormalizedCrosswordPack;
|
||||
layout: CrosswordLayout;
|
||||
settings: CrosswordSessionSettings;
|
||||
}) {
|
||||
const orderedEntries = useMemo(() => [...layout.entries].sort((a, b) => a.number - b.number || a.direction.localeCompare(b.direction)), [layout.entries]);
|
||||
const entryById = useMemo(() => new Map(orderedEntries.map((entry) => [entry.id, entry])), [orderedEntries]);
|
||||
const cellByKey = useMemo(() => new Map(layout.cells.map((cell) => [cell.key, cell])), [layout.cells]);
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
const [activeEntryId, setActiveEntryId] = useState(orderedEntries[0]?.id ?? "");
|
||||
const [activeCellKey, setActiveCellKey] = useState(orderedEntries[0]?.cellKeys[0] ?? "");
|
||||
const [clueTab, setClueTab] = useState<"across" | "down">(orderedEntries[0]?.direction ?? "across");
|
||||
const [actions, setActions] = useState<CrosswordAction[]>([]);
|
||||
const [checkedWrong, setCheckedWrong] = useState<Set<string>>(new Set());
|
||||
const [revealedCells, setRevealedCells] = useState<Set<string>>(new Set());
|
||||
const [alternateClues, setAlternateClues] = useState<Set<string>>(new Set());
|
||||
const [feedback, setFeedback] = useState(`${layout.entries.length} of ${layout.targetCount} target words placed.`);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
const [result, setResult] = useState<CrosswordRoundResult | null>(null);
|
||||
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
|
||||
const startedAt = useRef(0);
|
||||
const mobileInput = useRef<HTMLInputElement>(null);
|
||||
const activeEntry = entryById.get(activeEntryId) ?? orderedEntries[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (startedAt.current === 0) startedAt.current = Date.now();
|
||||
if (result) return;
|
||||
const timer = window.setInterval(() => setElapsedSeconds(Math.floor((Date.now() - startedAt.current) / 1000)), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [result]);
|
||||
|
||||
function selectEntry(entry: CrosswordPlacedEntry, cellKey = entry.cellKeys[0]) {
|
||||
setActiveEntryId(entry.id);
|
||||
setActiveCellKey(cellKey);
|
||||
setClueTab(entry.direction);
|
||||
}
|
||||
|
||||
function selectCell(cellKey: string) {
|
||||
const cell = cellByKey.get(cellKey);
|
||||
if (!cell) return;
|
||||
let nextId = cell.entryIds[0];
|
||||
if (cell.entryIds.includes(activeEntryId) && cell.entryIds.length > 1 && activeCellKey === cellKey) {
|
||||
nextId = cell.entryIds.find((id) => id !== activeEntryId) ?? nextId;
|
||||
} else if (!cell.entryIds.includes(activeEntryId)) {
|
||||
nextId = cell.entryIds.find((id) => entryById.get(id)?.direction === clueTab) ?? nextId;
|
||||
} else {
|
||||
nextId = activeEntryId;
|
||||
}
|
||||
const entry = entryById.get(nextId);
|
||||
if (entry) selectEntry(entry, cellKey);
|
||||
mobileInput.current?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function moveWithinEntry(offset: number) {
|
||||
if (!activeEntry) return;
|
||||
const index = Math.max(0, activeEntry.cellKeys.indexOf(activeCellKey));
|
||||
setActiveCellKey(activeEntry.cellKeys[Math.max(0, Math.min(activeEntry.cellKeys.length - 1, index + offset))]);
|
||||
}
|
||||
|
||||
function writeLetter(letter: string) {
|
||||
if (!activeEntry || !activeCellKey || result || revealedCells.has(activeCellKey)) return;
|
||||
const upper = letter.replace(/[^A-Za-z]/g, "").slice(-1).toUpperCase();
|
||||
if (!upper) return;
|
||||
const nextAnswers = { ...answers, [activeCellKey]: upper };
|
||||
setAnswers(nextAnswers);
|
||||
if (settings.instantCheck) {
|
||||
const value = activeEntry.cellKeys.map((cellKey) => nextAnswers[cellKey] ?? "").join("");
|
||||
if (value.length === activeEntry.answer.length) {
|
||||
setCheckedWrong((checked) => {
|
||||
const updated = new Set(checked);
|
||||
activeEntry.cellKeys.forEach((cellKey, index) => {
|
||||
if ((nextAnswers[cellKey] ?? "") === activeEntry.answer[index]) updated.delete(cellKey);
|
||||
else updated.add(cellKey);
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}
|
||||
setCheckedWrong((current) => { const next = new Set(current); next.delete(activeCellKey); return next; });
|
||||
moveWithinEntry(1);
|
||||
}
|
||||
|
||||
function clearLetter() {
|
||||
if (!activeCellKey || revealedCells.has(activeCellKey)) return;
|
||||
if (answers[activeCellKey]) {
|
||||
setAnswers((current) => { const next = { ...current }; delete next[activeCellKey]; return next; });
|
||||
setCheckedWrong((current) => { const next = new Set(current); next.delete(activeCellKey); return next; });
|
||||
} else {
|
||||
moveWithinEntry(-1);
|
||||
}
|
||||
}
|
||||
|
||||
function cycleEntry(offset: number) {
|
||||
const index = Math.max(0, orderedEntries.findIndex((entry) => entry.id === activeEntryId));
|
||||
const entry = orderedEntries[(index + offset + orderedEntries.length) % orderedEntries.length];
|
||||
selectEntry(entry);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent) {
|
||||
if (/^[a-zA-Z]$/.test(event.key)) { event.preventDefault(); writeLetter(event.key); return; }
|
||||
if (event.key === "Backspace" || event.key === "Delete") { event.preventDefault(); clearLetter(); return; }
|
||||
if (event.key === "Tab") { event.preventDefault(); cycleEntry(event.shiftKey ? -1 : 1); return; }
|
||||
if (event.key === "Enter") { event.preventDefault(); checkWord(); return; }
|
||||
const cell = cellByKey.get(activeCellKey);
|
||||
if (!cell || !event.key.startsWith("Arrow")) return;
|
||||
event.preventDefault();
|
||||
const delta = event.key === "ArrowLeft" ? [0, -1] : event.key === "ArrowRight" ? [0, 1] : event.key === "ArrowUp" ? [-1, 0] : [1, 0];
|
||||
const next = cellByKey.get(`${cell.row + delta[0]}:${cell.column + delta[1]}`);
|
||||
if (next) selectCell(next.key);
|
||||
}
|
||||
|
||||
function incorrectCells(entry: CrosswordPlacedEntry) {
|
||||
return entry.cellKeys.filter((cellKey, index) => (answers[cellKey] ?? "") !== entry.answer[index]);
|
||||
}
|
||||
|
||||
function checkWord() {
|
||||
if (!activeEntry) return;
|
||||
const wrong = incorrectCells(activeEntry);
|
||||
setCheckedWrong((current) => new Set([...current, ...wrong]));
|
||||
setActions((current) => [...current, { type: "CHECK_WORD", entryId: activeEntry.id, value: entryValue(activeEntry, answers), elapsedMs: Date.now() - startedAt.current }]);
|
||||
setFeedback(wrong.length === 0 ? `${activeEntry.number} ${activeEntry.direction} is correct.` : `${wrong.length} cell${wrong.length === 1 ? " is" : "s are"} incorrect in this word.`);
|
||||
}
|
||||
|
||||
function checkPuzzle() {
|
||||
const wrong = orderedEntries.flatMap(incorrectCells);
|
||||
setCheckedWrong(new Set(wrong));
|
||||
setActions((current) => [...current, { type: "CHECK_PUZZLE", answers: Object.fromEntries(orderedEntries.map((entry) => [entry.id, entryValue(entry, answers)])), elapsedMs: Date.now() - startedAt.current }]);
|
||||
setFeedback(wrong.length === 0 ? "Every filled answer is correct." : `${wrong.length} cells need another look.`);
|
||||
}
|
||||
|
||||
function showAlternateClue() {
|
||||
if (!activeEntry || !settings.allowHints) return;
|
||||
setAlternateClues((current) => new Set(current).add(activeEntry.id));
|
||||
setActions((current) => [...current, { type: "ALTERNATE_CLUE", entryId: activeEntry.id, elapsedMs: Date.now() - startedAt.current }]);
|
||||
}
|
||||
|
||||
function revealLetter() {
|
||||
if (!activeEntry || !activeCellKey || !settings.allowHints) return;
|
||||
const index = activeEntry.cellKeys.indexOf(activeCellKey);
|
||||
setAnswers((current) => ({ ...current, [activeCellKey]: activeEntry.answer[index] }));
|
||||
setRevealedCells((current) => new Set(current).add(activeCellKey));
|
||||
setCheckedWrong((current) => { const next = new Set(current); next.delete(activeCellKey); return next; });
|
||||
setActions((current) => [...current, { type: "REVEAL_LETTER", cellKey: activeCellKey, elapsedMs: Date.now() - startedAt.current }]);
|
||||
moveWithinEntry(1);
|
||||
}
|
||||
|
||||
function revealWord() {
|
||||
if (!activeEntry || !settings.allowHints) return;
|
||||
setAnswers((current) => ({ ...current, ...Object.fromEntries(activeEntry.cellKeys.map((cellKey, index) => [cellKey, activeEntry.answer[index]])) }));
|
||||
setRevealedCells((current) => new Set([...current, ...activeEntry.cellKeys]));
|
||||
setCheckedWrong((current) => { const next = new Set(current); activeEntry.cellKeys.forEach((cellKey) => next.delete(cellKey)); return next; });
|
||||
setActions((current) => [...current, { type: "REVEAL_WORD", entryId: activeEntry.id, elapsedMs: Date.now() - startedAt.current }]);
|
||||
setFeedback(`${activeEntry.number} ${activeEntry.direction} was revealed.`);
|
||||
}
|
||||
|
||||
async function finish(gaveUp: boolean) {
|
||||
if (saveState === "saving") return;
|
||||
if (!gaveUp && !window.confirm("Submit this puzzle and reveal the results?")) return;
|
||||
if (gaveUp && !window.confirm("Give up and reveal every remaining answer?")) return;
|
||||
setSaveState("saving");
|
||||
const durationSeconds = Math.floor((Date.now() - startedAt.current) / 1000);
|
||||
const response = await fetch(`/api/arcade/packs/${packId}/attempts`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mode: "CROSSWORD",
|
||||
durationSeconds,
|
||||
seed,
|
||||
settings,
|
||||
finalAnswers: Object.fromEntries(orderedEntries.map((entry) => [entry.id, entryValue(entry, answers)])),
|
||||
actions,
|
||||
gaveUp,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.results) { setSaveState("error"); setFeedback(data.error ?? "Attempt could not be saved."); return; }
|
||||
setElapsedSeconds(durationSeconds);
|
||||
setResult(data.results);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
return <ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/crossword`} elapsedSeconds={elapsedSeconds} complete worldClassName="crossword-world crossword-stage"><CrosswordResults result={result} layout={layout} answers={answers} revealedCells={revealedCells} exitHref={`/${classSlug}/arcade/crossword`} /></ArcadeGameShell>;
|
||||
}
|
||||
|
||||
const activeKeys = new Set(activeEntry?.cellKeys ?? []);
|
||||
const visibleClues = orderedEntries.filter((entry) => entry.direction === clueTab);
|
||||
const cellSize = Math.round(34 * zoom);
|
||||
return (
|
||||
<ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/crossword`} elapsedSeconds={elapsedSeconds} complete={false} worldClassName="crossword-world crossword-stage">
|
||||
<input ref={mobileInput} value="" onKeyDown={handleKeyDown} onChange={(event) => writeLetter(event.target.value)} className="crossword-mobile-input" aria-label="Type crossword letter" autoCapitalize="characters" inputMode="text" />
|
||||
<section className="crossword-active-clue mb-4 rounded-2xl p-4" aria-live="polite"><span>{activeEntry?.number} {activeEntry?.direction}</span><strong>{alternateClues.has(activeEntry?.id ?? "") ? activeEntry?.alternateClue : activeEntry?.clue}</strong><small>{feedback}</small></section>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_22rem]">
|
||||
<div>
|
||||
<div className="crossword-toolbar mb-3 flex flex-wrap items-center justify-between gap-2 rounded-xl p-2"><div className="flex gap-1"><button onClick={() => setZoom((value) => Math.max(.7, value - .1))} aria-label="Zoom out">−</button><span>{Math.round(zoom * 100)}%</span><button onClick={() => setZoom((value) => Math.min(1.5, value + .1))} aria-label="Zoom in">+</button></div><span>{layout.entries.length} placed · {layout.omittedEntries.length} omitted</span></div>
|
||||
<div className="crossword-board-viewport rounded-2xl" onKeyDown={handleKeyDown} tabIndex={0} aria-label="Crossword grid">
|
||||
<div className="crossword-board" style={{ "--crossword-cell": `${cellSize}px`, gridTemplateColumns: `repeat(${layout.columns}, var(--crossword-cell))`, gridTemplateRows: `repeat(${layout.rows}, var(--crossword-cell))` } as CSSProperties}>
|
||||
{layout.cells.map((cell) => <button key={cell.key} onClick={() => selectCell(cell.key)} aria-label={`Row ${cell.row + 1}, column ${cell.column + 1}${answers[cell.key] ? `, ${answers[cell.key]}` : ""}`} className={`crossword-cell ${activeKeys.has(cell.key) ? "is-word" : ""} ${activeCellKey === cell.key ? "is-active" : ""} ${checkedWrong.has(cell.key) ? "is-wrong" : ""} ${revealedCells.has(cell.key) ? "is-revealed" : ""}`} style={{ gridRow: cell.row + 1, gridColumn: cell.column + 1 }}><small>{cell.number}</small><strong>{answers[cell.key] ?? ""}</strong></button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
|
||||
<button onClick={checkWord} className="crossword-secondary">Check word</button><button onClick={checkPuzzle} className="crossword-secondary">Check puzzle</button>
|
||||
{settings.allowHints && <><button onClick={showAlternateClue} className="crossword-secondary">Alternate clue</button><button onClick={revealLetter} className="crossword-secondary">Reveal letter</button><button onClick={revealWord} className="crossword-secondary">Reveal word</button></>}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3"><button onClick={() => void finish(true)} className="crossword-secondary min-h-12">Give up</button><button onClick={() => void finish(false)} className="crossword-primary min-h-12 rounded-xl font-extrabold">Submit puzzle</button></div>
|
||||
{saveState === "error" && <p className="mt-2 text-sm text-error" role="alert">The attempt could not be saved. Your puzzle is still open.</p>}
|
||||
</div>
|
||||
<aside className="crossword-clues rounded-2xl p-4">
|
||||
<div className="grid grid-cols-2 gap-2">{(["across", "down"] as const).map((tab) => <button key={tab} aria-pressed={clueTab === tab} onClick={() => setClueTab(tab)} className={clueTab === tab ? "is-active" : ""}>{tab}</button>)}</div>
|
||||
<ol className="mt-4 space-y-2">{visibleClues.map((entry) => <li key={entry.id}><button onClick={() => selectEntry(entry)} className={entry.id === activeEntryId ? "is-active" : ""}><b>{entry.number}</b><span>{alternateClues.has(entry.id) ? entry.alternateClue : entry.clue}</span></button></li>)}</ol>
|
||||
</aside>
|
||||
</div>
|
||||
</ArcadeGameShell>
|
||||
);
|
||||
}
|
||||
|
||||
function CrosswordResults({ result, layout, answers, revealedCells, exitHref }: { result: CrosswordRoundResult; layout: CrosswordLayout; answers: Record<string, string>; revealedCells: Set<string>; exitHref: string }) {
|
||||
const ordered = result.entries.filter((entry) => !entry.omitted).sort((left, right) => {
|
||||
const rank = (entry: typeof left) => !entry.correct ? 0 : entry.revealedWord || entry.revealedLetters > 0 || entry.alternateClueUsed ? 1 : 2;
|
||||
return rank(left) - rank(right);
|
||||
});
|
||||
return <div className="crossword-results">
|
||||
<section className="crossword-results-hero rounded-3xl p-6 sm:p-8"><p className="crossword-kicker">Final edition</p><h2 className="editorial-title mt-1 text-4xl">{result.outcome === "GAVE_UP" ? "Answers revealed" : "Puzzle submitted"}</h2><div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">{[["Score", `${result.score}/${result.maxScore}`], ["Accuracy", `${Math.round(result.accuracy * 100)}%`], ["Hints", result.hintsUsed], ["Words", result.placedCount]].map(([label, value]) => <div key={label} className="rounded-xl p-3"><small>{label}</small><strong>{value}</strong></div>)}</div></section>
|
||||
<FinalCrosswordBoard layout={layout} answers={answers} revealedCells={revealedCells} />
|
||||
<div className="mt-5 space-y-3">{ordered.map((entry) => {
|
||||
const assisted = entry.revealedWord || entry.revealedLetters > 0;
|
||||
return <article key={entry.entryId} className={`crossword-review rounded-2xl p-4 ${assisted ? "is-assisted" : entry.correct ? "is-correct" : "is-incorrect"}`}><p className="text-xs font-extrabold uppercase tracking-wide">{assisted ? "Assisted" : entry.correct ? "Correct" : "Incorrect"}</p><h3 className="mt-1 text-lg font-extrabold">{entry.answer}</h3><p className="mt-1 text-sm"><strong>Clue:</strong> {entry.clue}</p><p className="mt-1 text-sm"><strong>Your answer:</strong> {entry.playerAnswer || "No answer"}</p><p className="mt-3 text-sm leading-6">{entry.explanation}</p></article>;
|
||||
})}</div>
|
||||
<div className="mt-6 grid grid-cols-2 gap-3"><button onClick={() => window.location.reload()} className="crossword-primary min-h-12 rounded-xl font-extrabold">New layout</button><a href={exitHref} className="crossword-secondary flex min-h-12 items-center justify-center">Back to banks</a></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function FinalCrosswordBoard({ layout, answers, revealedCells }: { layout: CrosswordLayout; answers: Record<string, string>; revealedCells: Set<string> }) {
|
||||
const cellSize = Math.max(18, Math.min(30, Math.floor(640 / Math.max(layout.rows, layout.columns))));
|
||||
return <section className="crossword-final-board mt-5 rounded-3xl p-4 sm:p-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3"><div><p className="crossword-kicker">Completed grid</p><h3 className="editorial-title mt-1 text-2xl">Your final board</h3></div><div className="crossword-final-legend"><span><i className="is-correct" />Correct</span><span><i className="is-assisted" />Revealed or assisted</span><span><i className="is-incorrect" />Incorrect or blank</span></div></div>
|
||||
<div className="crossword-final-viewport mt-4">
|
||||
<div className="crossword-final-grid" style={{ gridTemplateColumns: `repeat(${layout.columns}, ${cellSize}px)`, gridTemplateRows: `repeat(${layout.rows}, ${cellSize}px)` }}>
|
||||
{layout.cells.map((cell) => {
|
||||
const assisted = revealedCells.has(cell.key);
|
||||
const correct = answers[cell.key] === cell.answer;
|
||||
const state = assisted ? "assisted" : correct ? "correct" : "incorrect";
|
||||
return <div key={cell.key} className={`crossword-final-cell is-${state}`} style={{ gridColumn: cell.column + 1, gridRow: cell.row + 1 }} aria-label={`${cell.answer}, ${state === "incorrect" ? "incorrect or blank" : state}`}><small>{cell.number}</small><strong>{cell.answer}</strong></div>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
152
src/components/arcade/CrosswordHub.tsx
Normal file
152
src/components/arcade/CrosswordHub.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArcadeImportModal } from "@/components/arcade/ArcadeImportModal";
|
||||
import { CROSSWORD_SIZE_TARGETS } from "@/lib/arcade/crosswordEngine";
|
||||
import type { ArcadeAttemptSummary, ArcadePackSummary, CrosswordLayout, CrosswordSize } from "@/types/arcade";
|
||||
|
||||
const SIZES: { value: CrosswordSize; label: string; note: string }[] = [
|
||||
{ value: "mini", label: "Mini", note: "Up to 15 words" },
|
||||
{ value: "standard", label: "Standard", note: "Up to 30 words" },
|
||||
{ value: "large", label: "Large", note: "Up to 50 words" },
|
||||
{ value: "extra-large", label: "Extra Large", note: "Up to 80 words" },
|
||||
];
|
||||
|
||||
export function CrosswordHub({ classId, classSlug, initialPacks }: { classId: string; classSlug: string; initialPacks: ArcadePackSummary[] }) {
|
||||
const router = useRouter();
|
||||
const [selectedId, setSelectedId] = useState(initialPacks[0]?.id ?? "");
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [size, setSize] = useState<CrosswordSize>("standard");
|
||||
const [instantCheck, setInstantCheck] = useState(initialPacks[0]?.defaultInstantCheck ?? false);
|
||||
const [allowHints, setAllowHints] = useState(initialPacks[0]?.defaultAllowHints ?? true);
|
||||
const [attempts, setAttempts] = useState<ArcadeAttemptSummary[]>([]);
|
||||
const [attemptsLoading, setAttemptsLoading] = useState(initialPacks.length > 0);
|
||||
const [previewLayout, setPreviewLayout] = useState<CrosswordLayout | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(initialPacks.length > 0);
|
||||
const effectiveSelectedId = initialPacks.some((pack) => pack.id === selectedId) ? selectedId : initialPacks[0]?.id ?? "";
|
||||
const selected = useMemo(() => initialPacks.find((pack) => pack.id === effectiveSelectedId) ?? null, [effectiveSelectedId, initialPacks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
fetch(`/api/arcade/packs/${selected.id}/attempts`)
|
||||
.then((response) => response.ok ? response.json() : [])
|
||||
.then(setAttempts)
|
||||
.finally(() => setAttemptsLoading(false));
|
||||
}, [selected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
let current = true;
|
||||
fetch(`/api/arcade/packs/${selected.id}/layout?size=${size}`)
|
||||
.then((response) => response.ok ? response.json() : null)
|
||||
.then((layout) => { if (current) setPreviewLayout(layout); })
|
||||
.finally(() => { if (current) setPreviewLoading(false); });
|
||||
return () => { current = false; };
|
||||
}, [selected, size]);
|
||||
|
||||
function selectPack(pack: ArcadePackSummary) {
|
||||
if (pack.id === effectiveSelectedId) return;
|
||||
setSelectedId(pack.id);
|
||||
setInstantCheck(pack.defaultInstantCheck);
|
||||
setAllowHints(pack.defaultAllowHints);
|
||||
setAttempts([]);
|
||||
setAttemptsLoading(true);
|
||||
setPreviewLayout(null);
|
||||
setPreviewLoading(true);
|
||||
}
|
||||
|
||||
function selectSize(nextSize: CrosswordSize) {
|
||||
if (nextSize === size) return;
|
||||
setSize(nextSize);
|
||||
setPreviewLayout(null);
|
||||
setPreviewLoading(true);
|
||||
}
|
||||
|
||||
async function renamePack(pack: ArcadePackSummary) {
|
||||
const name = window.prompt("Rename Crossword pack", pack.name)?.trim();
|
||||
if (!name || name === pack.name) return;
|
||||
await fetch(`/api/arcade/packs/${pack.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) });
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function deletePack(pack: ArcadePackSummary) {
|
||||
if (!window.confirm(`Delete “${pack.name}” and its attempt history?`)) return;
|
||||
await fetch(`/api/arcade/packs/${pack.id}`, { method: "DELETE" });
|
||||
if (selectedId === pack.id) setSelectedId("");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const playHref = selected
|
||||
? `/${classSlug}/arcade/crossword/${selected.id}/play?size=${size}&instant=${instantCheck ? "1" : "0"}&hints=${allowHints ? "1" : "0"}`
|
||||
: "#";
|
||||
|
||||
return (
|
||||
<div className="crossword-world crossword-hub p-1 pb-20 sm:p-3">
|
||||
<section className="crossword-hero mb-7 rounded-3xl p-5 sm:flex sm:items-end sm:justify-between sm:p-7">
|
||||
<div>
|
||||
<Link href={`/${classSlug}/arcade`} className="mb-3 inline-flex min-h-10 items-center text-sm font-bold text-text-muted hover:text-primary">← Back to Arcade</Link>
|
||||
<p className="crossword-kicker">The evening edition</p>
|
||||
<h2 className="editorial-title mt-1 text-4xl text-text-heading">Crossword</h2>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-text-secondary">Choose a terminology bank, set the edition size, and work the clues at your own pace.</p>
|
||||
</div>
|
||||
<button onClick={() => setShowImport(true)} className="crossword-primary mt-5 min-h-12 rounded-xl px-5 text-sm font-bold sm:mt-0">Import puzzle bank</button>
|
||||
</section>
|
||||
|
||||
{initialPacks.length === 0 ? (
|
||||
<section className="crossword-paper rounded-3xl px-6 py-16 text-center">
|
||||
<div className="mx-auto mb-5 grid w-24 grid-cols-5 gap-1" aria-hidden>{Array.from({ length: 25 }, (_, index) => <i key={index} className={index % 3 === 0 ? "bg-[#2d2117]" : "bg-[#fff8e6]"} />)}</div>
|
||||
<h3 className="editorial-title text-3xl text-text-heading">Print your first edition</h3>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-text-secondary">Import one JSON object containing exactly 80 clue-and-answer entries.</p>
|
||||
<button onClick={() => setShowImport(true)} className="crossword-primary mt-6 min-h-11 rounded-xl px-5 text-sm font-bold">Import Crossword JSON</button>
|
||||
</section>
|
||||
) : (
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(22rem,.9fr)]">
|
||||
<section>
|
||||
<h3 className="crossword-section-label">Puzzle banks</h3>
|
||||
<div className="space-y-3">
|
||||
{initialPacks.map((pack) => {
|
||||
const active = pack.id === effectiveSelectedId;
|
||||
return <article key={pack.id} className={`crossword-pack rounded-2xl border p-4 ${active ? "is-active" : ""}`}>
|
||||
<button onClick={() => selectPack(pack)} className="w-full text-left">
|
||||
<div className="flex items-start justify-between gap-3"><div><h4 className="text-xl font-extrabold text-text-heading">{pack.name}</h4>{pack.description && <p className="mt-1 text-sm leading-5 text-text-secondary">{pack.description}</p>}</div><span className={`mt-1 h-4 w-4 rounded-full border-4 ${active ? "border-primary bg-white" : "border-border bg-bg-surface"}`} aria-hidden /></div>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs font-bold text-text-muted"><span>80 entries</span><span>Best {pack.bestScore ?? "—"}</span><span>Latest {pack.latestAttempt?.score ?? "—"}</span></div>
|
||||
</button>
|
||||
<div className="mt-3 flex justify-end gap-2 border-t border-border-light pt-3"><button onClick={() => renamePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-bg-surface-alt">Rename</button><button onClick={() => deletePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-error-bg hover:text-error">Delete</button></div>
|
||||
</article>;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="crossword-setup h-fit rounded-3xl p-5 lg:sticky lg:top-6">
|
||||
<h3 className="editorial-title text-2xl text-text-heading">Choose an edition</h3>
|
||||
{selected ? <>
|
||||
<p className="mt-1 text-sm font-bold text-primary">{selected.name}</p>
|
||||
<fieldset className="mt-5"><legend className="crossword-section-label">Board size</legend><div className="grid grid-cols-2 gap-2">{SIZES.map((option) => <button type="button" key={option.value} aria-pressed={size === option.value} onClick={() => selectSize(option.value)} className={`crossword-size rounded-xl p-3 text-left ${size === option.value ? "is-active" : ""}`}><strong className="block text-sm">{option.label}</strong><span className="text-xs">{option.note}</span></button>)}</div></fieldset>
|
||||
<CrosswordBoardPreview layout={previewLayout} loading={previewLoading} />
|
||||
<div className="mt-5 space-y-2">
|
||||
<label className="crossword-option"><input type="checkbox" checked={instantCheck} onChange={(event) => setInstantCheck(event.target.checked)} /><span><strong>Instant word checks</strong><small>Check only after a word is filled.</small></span></label>
|
||||
<label className="crossword-option"><input type="checkbox" checked={allowHints} onChange={(event) => setAllowHints(event.target.checked)} /><span><strong>Allow hints</strong><small>Alternate clues and reveals stay available.</small></span></label>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-between border-y border-border-light py-3 text-sm"><span className="text-text-secondary">Target words</span><strong>{CROSSWORD_SIZE_TARGETS[size]}</strong></div>
|
||||
<Link href={playHref} className="crossword-primary mt-5 flex min-h-12 items-center justify-center rounded-xl px-5 text-sm font-extrabold">Open the puzzle</Link>
|
||||
<div className="mt-7 border-t border-border-light pt-5"><h4 className="crossword-section-label">Recent editions</h4>{attemptsLoading ? <p className="mt-3 text-sm text-text-muted">Loading history…</p> : attempts.length === 0 ? <p className="mt-3 text-sm text-text-muted">No attempts yet.</p> : <div className="mt-3 space-y-2">{attempts.slice(0, 5).map((attempt) => <div key={attempt.id} className="flex items-center justify-between rounded-xl bg-bg-surface-alt px-3 py-2 text-sm"><strong>{attempt.score}/{attempt.maxScore}</strong><span className="text-text-muted">{Math.round(attempt.accuracy * 100)}% · {attempt.durationSeconds}s</span></div>)}</div>}</div>
|
||||
</> : <p className="mt-3 text-sm text-text-muted">Select a pack to configure a puzzle.</p>}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
{showImport && <ArcadeImportModal classId={classId} gameType="crossword" onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); router.refresh(); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CrosswordBoardPreview({ layout, loading }: { layout: CrosswordLayout | null; loading: boolean }) {
|
||||
const cellSize = layout ? Math.max(3, Math.min(8, Math.floor(190 / Math.max(layout.rows, layout.columns)))) : 6;
|
||||
return <section className="crossword-board-preview mt-4" aria-label="Selected size board preview" aria-busy={loading}>
|
||||
<div className="crossword-preview-heading"><span>Layout preview</span>{layout && <small>{layout.entries.length} words · {layout.columns} × {layout.rows}</small>}</div>
|
||||
<div className="crossword-preview-canvas">
|
||||
{loading ? <div className="crossword-preview-loading">Composing puzzle…</div> : layout ? <div className="crossword-preview-grid" style={{ gridTemplateColumns: `repeat(${layout.columns}, ${cellSize}px)`, gridTemplateRows: `repeat(${layout.rows}, ${cellSize}px)` }}>{layout.cells.map((cell) => <i key={cell.key} style={{ gridColumn: cell.column + 1, gridRow: cell.row + 1 }} />)}</div> : <p>Preview unavailable</p>}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
7
src/components/arcade/rendererRegistry.ts
Normal file
7
src/components/arcade/rendererRegistry.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { ConnectionsGame } from "@/components/arcade/ConnectionsGame";
|
||||
import { CrosswordGame } from "@/components/arcade/CrosswordGame";
|
||||
|
||||
export const ARCADE_RENDERERS = {
|
||||
connections: ConnectionsGame,
|
||||
crossword: CrosswordGame,
|
||||
} as const;
|
||||
|
|
@ -79,7 +79,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
|
|||
</span>
|
||||
<button
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
|
||||
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl bg-primary px-4 text-sm font-bold text-white transition-colors hover:bg-primary-hover"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
|
|
@ -92,7 +92,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
|
|||
{cards.map((card, index) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] overflow-hidden"
|
||||
className="overflow-hidden rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)]"
|
||||
>
|
||||
{editingId === card.id ? (
|
||||
<div className="p-4 space-y-3">
|
||||
|
|
@ -136,7 +136,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
|
|||
</div>
|
||||
) : (
|
||||
<div className="flex items-stretch">
|
||||
<div className="flex-1 p-4 grid grid-cols-2 gap-4">
|
||||
<div className="grid flex-1 grid-cols-1 gap-4 p-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="block text-xs font-medium text-text-muted uppercase tracking-wider mb-1">
|
||||
Front
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { recordStudyActivity } from "@/lib/activityClient";
|
||||
|
||||
interface Card {
|
||||
id: string;
|
||||
|
|
@ -55,6 +56,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
);
|
||||
const [swipeClass, setSwipeClass] = useState("");
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const gradingRef = useRef(false);
|
||||
|
||||
// Touch/drag state
|
||||
const dragRef = useRef({ startX: 0, currentX: 0, isDragging: false });
|
||||
|
|
@ -120,7 +122,12 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
// Grade the current card
|
||||
const gradeCard = useCallback(
|
||||
(grade: CardResult) => {
|
||||
if (!currentCard || !hasFlippedOnce) return;
|
||||
if (!currentCard || !hasFlippedOnce || gradingRef.current) return;
|
||||
gradingRef.current = true;
|
||||
|
||||
if (!isShared) {
|
||||
recordStudyActivity("FLASHCARD").catch(() => {});
|
||||
}
|
||||
|
||||
setToastMessage(null);
|
||||
const newResults = { ...results, [currentCard.id]: grade };
|
||||
|
|
@ -130,6 +137,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
setSwipeClass(grade === "correct" ? "animate-swipe-right" : "animate-swipe-left");
|
||||
|
||||
setTimeout(() => {
|
||||
gradingRef.current = false;
|
||||
setSwipeClass("");
|
||||
setIsFlipped(false);
|
||||
setHasFlippedOnce(false);
|
||||
|
|
@ -144,7 +152,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
}
|
||||
}, 350);
|
||||
},
|
||||
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled]
|
||||
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled, isShared]
|
||||
);
|
||||
|
||||
// Keyboard shortcuts
|
||||
|
|
@ -236,6 +244,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
|
||||
// Restart handlers
|
||||
function restartFullSet() {
|
||||
gradingRef.current = false;
|
||||
let newOrder: string[];
|
||||
if (isShuffled) {
|
||||
newOrder = [...cards.map((c) => c.id)].sort(() => Math.random() - 0.5);
|
||||
|
|
@ -277,13 +286,13 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
if (completed) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-8 max-w-md w-full text-center">
|
||||
<div className="w-full max-w-md rounded-3xl border border-border-light bg-bg-surface p-8 text-center shadow-[var(--shadow-card)]">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-success-bg mb-4">
|
||||
<svg className="w-7 h-7 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-text-heading mb-2">Set Complete!</h3>
|
||||
<h3 className="editorial-title mb-2 text-3xl text-text-heading">Set complete</h3>
|
||||
|
||||
<div className="flex justify-center gap-6 mb-6">
|
||||
<div className="text-center">
|
||||
|
|
@ -334,7 +343,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Running tally */}
|
||||
<div className="w-full max-w-4xl mb-6">
|
||||
<div className="mb-5 w-full max-w-4xl rounded-xl border border-border-light bg-bg-surface/70 px-4 py-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-success font-medium">{correctCount} ✓</span>
|
||||
|
|
@ -371,16 +380,16 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
>
|
||||
<div
|
||||
key={currentCard.id}
|
||||
className={`relative w-full min-h-[450px] md:min-h-[550px] preserve-3d ${!swipeClass ? "transition-transform duration-300" : ""} ${
|
||||
className={`relative w-full min-h-[420px] md:min-h-[560px] preserve-3d ${!swipeClass ? "transition-transform duration-300" : ""} ${
|
||||
isFlipped ? "rotate-x-180" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Front */}
|
||||
<div className="absolute inset-0 backface-hidden bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-8 md:p-12 flex flex-col items-center justify-center">
|
||||
<div className="paper-grid absolute inset-0 flex flex-col items-center justify-center rounded-[2rem] border border-border-light bg-bg-surface p-7 shadow-[var(--shadow-card-hover)] backface-hidden md:p-12">
|
||||
<span className="absolute top-3 right-4 text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Front
|
||||
</span>
|
||||
<div className="markdown-content text-center text-xl md:text-3xl text-text-heading leading-relaxed w-full">
|
||||
<div className="markdown-content w-full text-center font-serif text-2xl font-semibold leading-relaxed text-text-heading md:text-4xl">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{currentCard.front}
|
||||
</ReactMarkdown>
|
||||
|
|
@ -391,7 +400,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
</div>
|
||||
|
||||
{/* Back */}
|
||||
<div className="absolute inset-0 backface-hidden rotate-x-180 bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-8 md:p-12 flex flex-col items-center justify-center">
|
||||
<div className="paper-grid absolute inset-0 flex rotate-x-180 flex-col items-center justify-center rounded-[2rem] border border-primary/25 bg-bg-surface p-7 shadow-[var(--shadow-card-hover)] backface-hidden md:p-12">
|
||||
<span className="absolute top-3 right-4 text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Back
|
||||
</span>
|
||||
|
|
@ -437,12 +446,12 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
<div className="w-full mt-6">
|
||||
|
||||
{/* Primary Action Row (Grading) */}
|
||||
<div className={`flex items-center justify-center gap-4 min-h-[52px] mb-4 relative z-10 ${!hasFlippedOnce ? 'pointer-events-none' : ''}`}>
|
||||
<div className={`relative z-10 mb-4 flex min-h-[56px] items-center justify-center gap-3 sm:gap-4 ${!hasFlippedOnce ? 'pointer-events-none' : ''}`}>
|
||||
{hasFlippedOnce && (
|
||||
<div className="flex items-center gap-2 md:gap-4 animate-fade-in pointer-events-auto">
|
||||
<button
|
||||
onClick={() => gradeCard("missed")}
|
||||
className="flex items-center gap-2 px-6 py-3 rounded-xl border-2 border-error/30 text-error font-medium hover:bg-error-bg transition-all duration-200 cursor-pointer bg-bg-base/80 backdrop-blur"
|
||||
className="flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border-2 border-error/30 bg-bg-surface px-4 font-bold text-error transition-colors hover:bg-error-bg sm:flex-none sm:px-7"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
|
|
@ -451,7 +460,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
</button>
|
||||
<button
|
||||
onClick={() => gradeCard("correct")}
|
||||
className="flex items-center gap-2 px-6 py-3 rounded-xl border-2 border-success/30 text-success font-medium hover:bg-success-bg transition-all duration-200 cursor-pointer bg-bg-base/80 backdrop-blur"
|
||||
className="flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border-2 border-success/30 bg-bg-surface px-4 font-bold text-success transition-colors hover:bg-success-bg sm:flex-none sm:px-7"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
|
|
|
|||
|
|
@ -74,8 +74,8 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) {
|
|||
}
|
||||
|
||||
onCreated();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create deck");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,18 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" }) {
|
||||
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" | "connections" | "crossword" }) {
|
||||
const [instructions, setInstructions] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveResult, setSaveResult] = useState<"success" | "error" | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [packCount, setPackCount] = useState(1);
|
||||
|
||||
const batchOverride = importType === "connections" && packCount > 1
|
||||
? `TEMPORARY BATCH OVERRIDE:\nGenerate exactly ${packCount} distinct Connections packs. Return one raw JSON array containing exactly ${packCount} objects that each follow the schema below. Make the packs meaningfully different from one another. This override takes precedence over any later instruction to return one object.\n\n`
|
||||
: "";
|
||||
const displayedInstructions = `${batchOverride}${instructions}`;
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/settings/llm-instructions?type=${importType}`)
|
||||
|
|
@ -52,7 +58,7 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
|
|||
}
|
||||
|
||||
async function handleCopy() {
|
||||
await navigator.clipboard.writeText(instructions);
|
||||
await navigator.clipboard.writeText(displayedInstructions);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
|
@ -67,9 +73,23 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
|
|||
Copy these instructions and paste them into an LLM chat along with your study material. The LLM will generate JSON you can paste into the Import tab.
|
||||
</p>
|
||||
|
||||
{importType === "connections" && (
|
||||
<div className="rounded-xl border border-border bg-bg-surface-alt/60 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<label htmlFor="connections-pack-count" className="text-sm font-bold text-text-heading">Number of game packs</label>
|
||||
<p className="text-xs text-text-muted">This temporarily updates the copied prompt. Your saved instructions stay unchanged.</p>
|
||||
</div>
|
||||
<output htmlFor="connections-pack-count" className="grid h-11 min-w-12 place-items-center rounded-lg border border-border bg-bg-surface px-3 text-lg font-extrabold text-text-heading">{packCount}</output>
|
||||
</div>
|
||||
<input id="connections-pack-count" type="range" min={1} max={10} step={1} value={packCount} onChange={(event) => setPackCount(Number(event.target.value))} className="mt-4 w-full accent-primary" />
|
||||
<div className="mt-1 flex justify-between text-[10px] font-bold text-text-muted" aria-hidden><span>1</span><span>5</span><span>10</span></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
value={displayedInstructions}
|
||||
onChange={(event) => setInstructions(batchOverride && event.target.value.startsWith(batchOverride) ? event.target.value.slice(batchOverride.length) : event.target.value)}
|
||||
rows={14}
|
||||
className="w-full px-4 py-3 rounded-lg border border-border bg-bg-surface-alt/50 text-sm text-text-body font-mono leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200 resize-y"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export function ImportModal({
|
|||
const [activeTab, setActiveTab] = useState<"generate" | "import" | "create">("import");
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 backdrop-blur-sm animate-fade-in"
|
||||
|
|
@ -29,14 +29,15 @@ export function ImportModal({
|
|||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-2xl max-h-[90vh] bg-bg-surface rounded-2xl shadow-[var(--shadow-modal)] animate-slide-up flex flex-col">
|
||||
<div className="relative flex max-h-[92vh] w-full max-w-2xl animate-slide-up flex-col rounded-t-[2rem] border border-border-light bg-bg-surface shadow-[var(--shadow-modal)] sm:rounded-[2rem]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 pt-5 pb-0">
|
||||
<h2 className="text-lg font-semibold text-text-heading">
|
||||
<h2 className="editorial-title text-2xl text-text-heading">
|
||||
Import {importType === "flashcards" ? "Flashcard Deck" : "Quiz"}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close import dialog"
|
||||
className="p-1.5 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
|
|||
|
|
@ -2,15 +2,16 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { QuizResults } from "./QuizResults";
|
||||
import type { QuizAttempt, QuizSummary } from "@/types/study";
|
||||
|
||||
interface AttemptHistoryProps {
|
||||
quiz: any;
|
||||
attempts: any[];
|
||||
quiz: QuizSummary;
|
||||
attempts: QuizAttempt[];
|
||||
onRetake: (missedIds: string[]) => void;
|
||||
}
|
||||
|
||||
export function AttemptHistory({ quiz, attempts, onRetake }: AttemptHistoryProps) {
|
||||
const [selectedAttempt, setSelectedAttempt] = useState<any | null>(null);
|
||||
const [selectedAttempt, setSelectedAttempt] = useState<QuizAttempt | null>(null);
|
||||
|
||||
if (selectedAttempt) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,20 +2,20 @@
|
|||
|
||||
import { Fragment } from "react";
|
||||
import { scoreQuestion } from "@/lib/scoring";
|
||||
import type { QuizSummary } from "@/types/study";
|
||||
|
||||
interface CategoryBreakdownProps {
|
||||
quiz: any;
|
||||
attempt: any;
|
||||
quiz: QuizSummary;
|
||||
answeredIds: string[];
|
||||
answers: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export function CategoryBreakdown({ quiz, attempt, answeredIds, answers }: CategoryBreakdownProps) {
|
||||
export function CategoryBreakdown({ quiz, answeredIds, answers }: CategoryBreakdownProps) {
|
||||
// Aggregate stats per category
|
||||
const stats: Record<string, { earned: number; possible: number }> = {};
|
||||
|
||||
answeredIds.forEach((qId) => {
|
||||
const q = quiz.questions.find((x: any) => x.id === qId);
|
||||
const q = quiz.questions.find((x) => x.id === qId);
|
||||
if (!q) return;
|
||||
|
||||
const cat = q.category.trim().toLowerCase();
|
||||
|
|
@ -23,7 +23,11 @@ export function CategoryBreakdown({ quiz, attempt, answeredIds, answers }: Categ
|
|||
stats[cat] = { earned: 0, possible: 0 };
|
||||
}
|
||||
|
||||
const formattedQ = { id: q.id, type: q.type, options: q.options };
|
||||
const formattedQ = {
|
||||
id: q.id,
|
||||
type: q.type as "MULTIPLE_CHOICE" | "SATA",
|
||||
options: q.options,
|
||||
};
|
||||
const points = scoreQuestion(formattedQ, answers[q.id] || []);
|
||||
|
||||
stats[cat].earned += points;
|
||||
|
|
|
|||
|
|
@ -4,14 +4,21 @@ import ReactMarkdown from "react-markdown";
|
|||
import remarkGfm from "remark-gfm";
|
||||
import { CategoryBreakdown } from "./CategoryBreakdown";
|
||||
import { scoreQuestion } from "@/lib/scoring";
|
||||
import type { QuizAttempt, QuizQuestion, QuizSummary } from "@/types/study";
|
||||
|
||||
interface QuizResultsProps {
|
||||
quiz: any; // QuizData
|
||||
attempt: any; // QuizAttempt
|
||||
quiz: QuizSummary;
|
||||
attempt: QuizAttempt;
|
||||
onRetake: (missedIds: string[]) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
interface ReviewItem {
|
||||
question: QuizQuestion;
|
||||
score: number;
|
||||
selections: string[];
|
||||
}
|
||||
|
||||
export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsProps) {
|
||||
let answers: Record<string, string[]> = {};
|
||||
try {
|
||||
|
|
@ -19,16 +26,16 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
} catch {}
|
||||
|
||||
// Determine non-full-credit questions for review list
|
||||
const reviewItems: any[] = [];
|
||||
const reviewItems: ReviewItem[] = [];
|
||||
const missedIds: string[] = [];
|
||||
|
||||
const answeredIds = Object.keys(answers);
|
||||
const questionsToReview = quiz.questions.filter((q: any) => answeredIds.includes(q.id));
|
||||
const questionsToReview = quiz.questions.filter((q) => answeredIds.includes(q.id));
|
||||
|
||||
questionsToReview.forEach((q: any) => {
|
||||
questionsToReview.forEach((q) => {
|
||||
const formattedQ = {
|
||||
id: q.id,
|
||||
type: q.type,
|
||||
type: q.type as "MULTIPLE_CHOICE" | "SATA",
|
||||
options: q.options,
|
||||
};
|
||||
const score = scoreQuestion(formattedQ, answers[q.id] || []);
|
||||
|
|
@ -50,7 +57,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
<div className="max-w-3xl mx-auto space-y-8 animate-fade-in pb-12">
|
||||
|
||||
{/* Top Banner */}
|
||||
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-8 text-center relative overflow-hidden">
|
||||
<div className="relative overflow-hidden rounded-[2rem] border border-border-light bg-bg-surface p-6 text-center shadow-[var(--shadow-card-hover)] sm:p-8">
|
||||
{percentage >= 80 && (
|
||||
<div className="absolute top-0 left-0 w-full h-2 bg-success"></div>
|
||||
)}
|
||||
|
|
@ -61,7 +68,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
<div className="absolute top-0 left-0 w-full h-2 bg-error"></div>
|
||||
)}
|
||||
|
||||
<h2 className="text-2xl font-bold text-text-heading mb-2">Quiz Complete</h2>
|
||||
<h2 className="editorial-title mb-2 text-4xl text-text-heading">Quiz complete</h2>
|
||||
{attempt.isPartialRetake && (
|
||||
<p className="text-sm text-text-secondary mb-4">(Partial Retake)</p>
|
||||
)}
|
||||
|
|
@ -71,7 +78,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
<span className="text-lg text-text-muted mb-1 font-medium">({parseFloat(attempt.score.toFixed(2))} / {attempt.maxScore})</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
<div className="flex flex-col justify-center gap-3 sm:flex-row">
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
|
|
@ -92,7 +99,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
|
||||
<button
|
||||
onClick={() => {
|
||||
const allIds = quiz.questions.map((q: any) => q.id).sort(() => Math.random() - 0.5);
|
||||
const allIds = quiz.questions.map((q) => q.id).sort(() => Math.random() - 0.5);
|
||||
onRetake(allIds);
|
||||
}}
|
||||
className="px-6 py-2.5 rounded-lg border border-primary text-primary font-medium hover:bg-primary/5 transition-all duration-200 cursor-pointer"
|
||||
|
|
@ -105,7 +112,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
{/* Category Breakdown */}
|
||||
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-6 md:p-8">
|
||||
<h3 className="text-lg font-bold text-text-heading mb-6">Category Breakdown</h3>
|
||||
<CategoryBreakdown quiz={quiz} attempt={attempt} answeredIds={answeredIds} answers={answers} />
|
||||
<CategoryBreakdown quiz={quiz} answeredIds={answeredIds} answers={answers} />
|
||||
</div>
|
||||
|
||||
{/* Review List */}
|
||||
|
|
@ -113,7 +120,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
<div className="space-y-6">
|
||||
<h3 className="text-xl font-bold text-text-heading px-2">Areas for Review</h3>
|
||||
|
||||
{reviewItems.map((item, idx) => {
|
||||
{reviewItems.map((item) => {
|
||||
const q = item.question;
|
||||
const sels = item.selections;
|
||||
return (
|
||||
|
|
@ -128,7 +135,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
</div>
|
||||
|
||||
<div className="space-y-2 mb-6">
|
||||
{q.options.map((opt: any) => {
|
||||
{q.options.map((opt) => {
|
||||
const isSelected = sels.includes(opt.id);
|
||||
let stateClass = "cursor-default opacity-80";
|
||||
let icon = null;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { scoreQuestion } from "@/lib/scoring";
|
||||
import { QuizResults } from "./QuizResults";
|
||||
import { recordStudyActivity } from "@/lib/activityClient";
|
||||
import type { QuizAttempt } from "@/types/study";
|
||||
|
||||
interface Option {
|
||||
id: string;
|
||||
|
|
@ -39,6 +41,7 @@ interface QuizViewerProps {
|
|||
}
|
||||
|
||||
export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: QuizViewerProps) {
|
||||
const submittingQuestionRef = useRef<string | null>(null);
|
||||
const [order, setOrder] = useState<string[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState<Record<string, string[]>>({});
|
||||
|
|
@ -46,7 +49,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
|||
const [shuffledOptions, setShuffledOptions] = useState<Record<string, Option[]>>({});
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resultsData, setResultsData] = useState<any | null>(null);
|
||||
const [resultsData, setResultsData] = useState<QuizAttempt | null>(null);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
|
||||
// Auto-hide toast
|
||||
|
|
@ -194,7 +197,11 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
|||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (isSubmitted || !currentQId) return;
|
||||
if (isSubmitted || !currentQId || submittingQuestionRef.current === currentQId) return;
|
||||
submittingQuestionRef.current = currentQId;
|
||||
if (!isShared) {
|
||||
recordStudyActivity("QUIZ_QUESTION").catch(() => {});
|
||||
}
|
||||
const newSubmitted = [...submittedAnswers, currentQId];
|
||||
setSubmittedAnswers(newSubmitted);
|
||||
saveProgress(currentIndex, answers);
|
||||
|
|
@ -202,6 +209,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
|||
|
||||
function handleNext() {
|
||||
if (currentIndex + 1 < order.length) {
|
||||
submittingQuestionRef.current = null;
|
||||
const nextIdx = currentIndex + 1;
|
||||
setCurrentIndex(nextIdx);
|
||||
saveProgress(nextIdx, answers);
|
||||
|
|
@ -290,6 +298,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
|||
setCurrentIndex(0);
|
||||
setAnswers({});
|
||||
setSubmittedAnswers([]);
|
||||
submittingQuestionRef.current = null;
|
||||
setResultsData(null);
|
||||
|
||||
// Re-shuffle options for the new attempt
|
||||
|
|
@ -320,10 +329,10 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
|||
)}
|
||||
|
||||
{/* Main Unified Card */}
|
||||
<div className="w-full max-w-3xl bg-bg-surface-alt rounded-2xl border border-border-light shadow-[var(--shadow-card)] overflow-hidden mb-8 mt-12">
|
||||
<div className="mb-8 mt-4 w-full max-w-4xl overflow-hidden rounded-[2rem] border border-border-light bg-bg-surface shadow-[var(--shadow-card-hover)] md:mt-8">
|
||||
|
||||
{/* Progress Header */}
|
||||
<div className="px-6 md:px-8 py-5 flex items-center gap-4">
|
||||
<div className="flex items-center gap-4 border-b border-border-light bg-bg-surface-alt/55 px-5 py-4 md:px-8">
|
||||
<div className="text-sm font-mono text-text-secondary whitespace-nowrap tracking-wider">
|
||||
Q {currentIndex + 1} / {order.length}
|
||||
</div>
|
||||
|
|
@ -339,20 +348,20 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
|||
</div>
|
||||
|
||||
{/* Question Area */}
|
||||
<div className="px-6 md:px-8 pb-8 pt-2">
|
||||
<div className="px-5 pb-6 pt-6 md:px-10 md:pb-10 md:pt-8">
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<span className="inline-flex px-3 py-1 rounded-md bg-primary/10 text-primary text-xs font-bold uppercase tracking-wider">
|
||||
<div className="mb-8 flex items-start justify-between gap-3">
|
||||
<span className="inline-flex w-fit min-w-0 max-w-[48%] px-3 py-1 rounded-md bg-primary/10 text-primary text-xs font-bold uppercase tracking-wider leading-snug whitespace-normal">
|
||||
{currentQ.category}
|
||||
</span>
|
||||
<span className="inline-flex px-3 py-1 rounded-md bg-amber-500/10 text-amber-600 text-xs font-bold uppercase tracking-wider">
|
||||
<span className="inline-flex w-fit min-w-0 max-w-[48%] justify-end px-3 py-1 rounded-md bg-amber-500/10 text-right text-amber-600 text-xs font-bold uppercase tracking-wider leading-snug whitespace-normal">
|
||||
{currentQ.type === "MULTIPLE_CHOICE" ? "Multiple Choice" : "Select All That Apply"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Prompt */}
|
||||
<div className="markdown-content text-xl text-text-heading mb-8 font-serif leading-relaxed font-semibold">
|
||||
<div className="markdown-content editorial-title mb-8 text-2xl leading-relaxed text-text-heading md:text-3xl">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{currentQ.prompt}
|
||||
</ReactMarkdown>
|
||||
|
|
@ -374,7 +383,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
|||
} else if (!isSelected && opt.isCorrect) {
|
||||
stateClass = "border-success/50 bg-success/5 text-text-heading";
|
||||
rightIcon = (
|
||||
<div className="text-success flex items-center gap-1.5 text-sm font-bold">
|
||||
<div className="flex items-center gap-1.5 text-sm font-bold text-success">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
|
|
@ -392,7 +401,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
|||
<div
|
||||
key={opt.id}
|
||||
onClick={() => toggleOption(opt.id)}
|
||||
className={`flex items-center gap-4 px-5 py-4 rounded-xl border transition-all duration-200 ${stateClass}`}
|
||||
className={`grid min-h-14 grid-cols-[auto_minmax(0,1fr)] items-center gap-x-4 gap-y-2 rounded-2xl border px-4 py-4 transition-all duration-200 md:flex md:gap-4 md:px-5 ${stateClass}`}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`w-5 h-5 rounded ${currentQ.type === 'SATA' ? 'border' : 'border rounded-full'} flex items-center justify-center transition-colors ${
|
||||
|
|
@ -416,7 +425,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
|||
{opt.text}
|
||||
</div>
|
||||
{rightIcon && (
|
||||
<div className="flex-shrink-0 pl-2">
|
||||
<div className="col-start-2 min-w-0 md:flex-shrink-0 md:pl-2">
|
||||
{rightIcon}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -478,7 +487,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
|||
})()}
|
||||
|
||||
{/* Action Button */}
|
||||
<div className="mt-10 flex">
|
||||
<div className="sticky bottom-3 z-10 mt-10 flex rounded-2xl bg-bg-surface/90 p-1 backdrop-blur md:static md:bg-transparent md:p-0">
|
||||
{!isSubmitted ? (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
|
|
|
|||
365
src/components/spaced-repetition/SpacedRepetitionSets.tsx
Normal file
365
src/components/spaced-repetition/SpacedRepetitionSets.tsx
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
"use client";
|
||||
|
||||
/* eslint-disable react-hooks/refs -- @dnd-kit intentionally returns render-time ref callbacks and drag state. */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
pointerWithin,
|
||||
useDraggable,
|
||||
useDndContext,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
interface DeckSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
sortOrder?: number;
|
||||
_count: { cards: number };
|
||||
}
|
||||
|
||||
interface SetSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
newCardsPerDay: number;
|
||||
decks: DeckSummary[];
|
||||
stats: {
|
||||
totalCards: number;
|
||||
reviewedCards: number;
|
||||
unseenCards: number;
|
||||
dueCards: number;
|
||||
learningAheadCards: number;
|
||||
newCardsAvailable: number;
|
||||
availableCards: number;
|
||||
nextDue: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface EditState {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
newCardsPerDay: number;
|
||||
}
|
||||
|
||||
function notifyStudyAvailabilityChanged() {
|
||||
window.dispatchEvent(new Event("spaced-repetition-updated"));
|
||||
}
|
||||
|
||||
function SourceDeck({ deck }: { deck: DeckSummary }) {
|
||||
const drag = useDraggable({
|
||||
id: `source:${deck.id}`,
|
||||
data: { deckId: deck.id, sourceSetId: null },
|
||||
});
|
||||
return (
|
||||
<div
|
||||
ref={drag.setNodeRef}
|
||||
style={{ transform: CSS.Translate.toString(drag.transform), opacity: drag.isDragging ? 0.45 : 1 }}
|
||||
className="flex min-w-0 items-center gap-3 rounded-xl border border-border-light bg-bg-surface p-4 shadow-sm"
|
||||
>
|
||||
<button
|
||||
{...drag.attributes}
|
||||
{...drag.listeners}
|
||||
className="grid h-10 w-10 shrink-0 place-items-center rounded-lg text-text-muted hover:bg-bg-surface-alt hover:text-text-heading"
|
||||
aria-label={`Drag ${deck.name} into a repetition set`}
|
||||
>
|
||||
<span aria-hidden>☷</span>
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-bold text-text-heading">{deck.name}</p>
|
||||
<p className="text-xs text-text-muted">{deck._count.cards} cards</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MembershipDeck({ deck, setId, onRemove }: {
|
||||
deck: DeckSummary;
|
||||
setId: string;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const sortable = useSortable({
|
||||
id: `set:${setId}:deck:${deck.id}`,
|
||||
data: { deckId: deck.id, sourceSetId: setId, setId },
|
||||
});
|
||||
return (
|
||||
<div
|
||||
ref={sortable.setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(sortable.transform),
|
||||
transition: sortable.transition,
|
||||
opacity: sortable.isDragging ? 0.4 : 1,
|
||||
}}
|
||||
className="flex min-w-0 items-center gap-2 rounded-xl border border-border-light bg-bg-surface-alt/55 p-3"
|
||||
>
|
||||
<button
|
||||
{...sortable.attributes}
|
||||
{...sortable.listeners}
|
||||
className="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-text-muted hover:bg-bg-surface hover:text-text-heading"
|
||||
aria-label={`Reorder or copy ${deck.name}`}
|
||||
>
|
||||
<span aria-hidden>☷</span>
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold text-text-heading">{deck.name}</p>
|
||||
<p className="text-xs text-text-muted">{deck._count.cards} cards</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-text-muted hover:bg-error-bg hover:text-error"
|
||||
aria-label={`Remove ${deck.name} from this repetition set`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SetDropZone({ set, children }: { set: SetSummary; children: React.ReactNode }) {
|
||||
const drop = useDroppable({ id: `set:${set.id}`, data: { setId: set.id } });
|
||||
const { over } = useDndContext();
|
||||
const isOver =
|
||||
over?.id === `set:${set.id}` || over?.data.current?.setId === set.id;
|
||||
return (
|
||||
<div
|
||||
ref={drop.setNodeRef}
|
||||
className={`grid min-h-20 grid-cols-1 gap-3 rounded-xl border-2 border-dashed p-3 sm:grid-cols-2 ${
|
||||
isOver ? "border-primary bg-primary/5" : "border-border-light"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpacedRepetitionSets() {
|
||||
const { classSlug } = useParams<{ classSlug: string }>();
|
||||
const [classId, setClassId] = useState("");
|
||||
const [sets, setSets] = useState<SetSummary[]>([]);
|
||||
const [decks, setDecks] = useState<DeckSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [editing, setEditing] = useState<EditState | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const classesResponse = await fetch("/api/classes");
|
||||
const classes = (await classesResponse.json()) as Array<{ id: string; slug: string }>;
|
||||
const currentClass = classes.find((item) => item.slug === classSlug);
|
||||
if (!currentClass) throw new Error("Class not found");
|
||||
setClassId(currentClass.id);
|
||||
const [setsResponse, decksResponse] = await Promise.all([
|
||||
fetch(`/api/spaced-repetition-sets?classId=${currentClass.id}`),
|
||||
fetch(`/api/decks/list?classId=${currentClass.id}`),
|
||||
]);
|
||||
if (!setsResponse.ok || !decksResponse.ok) throw new Error("Unable to load repetition sets");
|
||||
setSets(await setsResponse.json());
|
||||
setDecks(await decksResponse.json());
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "Unable to load repetition sets");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [classSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => void load(), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
|
||||
async function createSet() {
|
||||
if (!newName.trim()) return;
|
||||
const response = await fetch("/api/spaced-repetition-sets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ classId, name: newName, newCardsPerDay: 30 }),
|
||||
});
|
||||
if (!response.ok) return setMessage("Could not create repetition set");
|
||||
setNewName("");
|
||||
setCreating(false);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editing) return;
|
||||
const response = await fetch(`/api/spaced-repetition-sets/${editing.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: editing.name,
|
||||
description: editing.description,
|
||||
newCardsPerDay: editing.newCardsPerDay,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) return setMessage("Could not update repetition set");
|
||||
setEditing(null);
|
||||
notifyStudyAvailabilityChanged();
|
||||
await load();
|
||||
}
|
||||
|
||||
async function deleteSet(set: SetSummary) {
|
||||
if (!confirm(`Delete “${set.name}”? Its spaced-repetition schedules will be permanently removed.`)) return;
|
||||
const response = await fetch(`/api/spaced-repetition-sets/${set.id}`, { method: "DELETE" });
|
||||
if (!response.ok) return setMessage("Could not delete repetition set");
|
||||
notifyStudyAvailabilityChanged();
|
||||
await load();
|
||||
}
|
||||
|
||||
async function removeDeck(set: SetSummary, deck: DeckSummary) {
|
||||
if (!confirm(`Remove “${deck.name}” from “${set.name}”? Its scheduling history in this set will be reset.`)) return;
|
||||
const response = await fetch(`/api/spaced-repetition-sets/${set.id}/decks/${deck.id}`, { method: "DELETE" });
|
||||
if (!response.ok) return setMessage("Could not remove deck");
|
||||
notifyStudyAvailabilityChanged();
|
||||
await load();
|
||||
}
|
||||
|
||||
async function addDeck(setId: string, deckId: string) {
|
||||
const target = sets.find((set) => set.id === setId);
|
||||
const deck = decks.find((item) => item.id === deckId);
|
||||
if (target?.decks.some((item) => item.id === deckId)) {
|
||||
setMessage(`${deck?.name ?? "That deck"} is already in ${target.name}.`);
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`/api/spaced-repetition-sets/${setId}/decks`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ deckId }),
|
||||
});
|
||||
if (!response.ok) return setMessage((await response.json()).error ?? "Could not add deck");
|
||||
setMessage(`${deck?.name ?? "Deck"} added.`);
|
||||
notifyStudyAvailabilityChanged();
|
||||
await load();
|
||||
}
|
||||
|
||||
async function handleDragEnd(event: DragEndEvent) {
|
||||
const activeData = event.active.data.current as { deckId?: string; sourceSetId?: string | null } | undefined;
|
||||
const overData = event.over?.data.current as { setId?: string; deckId?: string } | undefined;
|
||||
if (!activeData?.deckId || !overData?.setId) return;
|
||||
const targetSetId = overData.setId;
|
||||
|
||||
if (activeData.sourceSetId === targetSetId && overData.deckId) {
|
||||
const target = sets.find((set) => set.id === targetSetId);
|
||||
if (!target) return;
|
||||
const oldIndex = target.decks.findIndex((deck) => deck.id === activeData.deckId);
|
||||
const newIndex = target.decks.findIndex((deck) => deck.id === overData.deckId);
|
||||
if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return;
|
||||
const ordered = arrayMove(target.decks, oldIndex, newIndex);
|
||||
setSets((current) => current.map((set) => set.id === targetSetId ? { ...set, decks: ordered } : set));
|
||||
const response = await fetch(`/api/spaced-repetition-sets/${targetSetId}/decks`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ deckIds: ordered.map((deck) => deck.id) }),
|
||||
});
|
||||
if (!response.ok) await load();
|
||||
return;
|
||||
}
|
||||
await addDeck(targetSetId, activeData.deckId);
|
||||
}
|
||||
|
||||
if (loading) return <div className="animate-subtle-pulse py-16 text-center text-text-muted">Loading repetition sets…</div>;
|
||||
|
||||
return (
|
||||
<div className="pb-20">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Adaptive review</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Spaced repetition</h2></div>
|
||||
<button onClick={() => setCreating(true)} className="min-h-12 rounded-xl bg-primary px-5 font-bold text-white shadow-sm hover:bg-primary-hover">New repetition set</button>
|
||||
</div>
|
||||
|
||||
{message && <div className="mb-5 flex items-center justify-between rounded-xl border border-primary/20 bg-bg-callout px-4 py-3 text-sm text-text-heading"><span>{message}</span><button onClick={() => setMessage(null)} aria-label="Dismiss message">×</button></div>}
|
||||
|
||||
{creating && (
|
||||
<div className="mb-6 flex flex-col gap-3 rounded-2xl border border-primary/20 bg-bg-surface p-5 sm:flex-row">
|
||||
<input autoFocus value={newName} onChange={(event) => setNewName(event.target.value)} onKeyDown={(event) => event.key === "Enter" && createSet()} placeholder="e.g. Patho comprehensive review" className="min-h-11 flex-1 rounded-lg border border-border bg-bg-surface-alt px-3 text-text-heading" />
|
||||
<button onClick={createSet} className="rounded-lg bg-primary px-5 font-bold text-white">Create</button>
|
||||
<button onClick={() => { setCreating(false); setNewName(""); }} className="rounded-lg border border-border px-5 font-bold text-text-heading">Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragEnd={handleDragEnd}>
|
||||
<div className="space-y-6">
|
||||
{sets.length === 0 && <div className="rounded-2xl border border-dashed border-border p-10 text-center"><h3 className="font-bold text-text-heading">No repetition sets yet</h3><p className="mt-1 text-sm text-text-secondary">Create one, then drag flashcard decks into it.</p></div>}
|
||||
{sets.map((set) => (
|
||||
<section key={set.id} className="rounded-2xl border border-border-light bg-bg-surface/75 p-4 shadow-[var(--shadow-card)] sm:p-5">
|
||||
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-xl font-extrabold text-text-heading">{set.name}</h3>
|
||||
{set.description && <p className="mt-1 text-sm text-text-secondary">{set.description}</p>}
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs">
|
||||
<span className="rounded-full bg-badge-bg px-2.5 py-1 text-badge-text">{set.stats.totalCards} cards</span>
|
||||
<span className="rounded-full bg-error-bg px-2.5 py-1 text-error">{set.stats.dueCards} due</span>
|
||||
{set.stats.learningAheadCards > 0 && <span className="rounded-full bg-bg-callout px-2.5 py-1 text-primary">{set.stats.learningAheadCards} learning</span>}
|
||||
<span className="rounded-full bg-success-bg px-2.5 py-1 text-success">{set.stats.newCardsAvailable} new today</span>
|
||||
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1 text-text-muted">Limit {set.newCardsPerDay}/day</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{set.stats.totalCards === 0 ? <button disabled className="min-h-10 rounded-lg bg-bg-surface-alt px-5 text-sm font-bold text-text-muted">Add decks to study</button> : set.stats.availableCards > 0 ? (
|
||||
<Link href={`/${classSlug}/spaced-repetition/${set.id}`} className="inline-flex min-h-10 items-center rounded-lg bg-primary px-5 text-sm font-bold text-white hover:bg-primary-hover">{set.stats.reviewedCards === 0 ? "Study" : "Continue"} · {set.stats.availableCards}</Link>
|
||||
) : <Link href={`/${classSlug}/spaced-repetition/${set.id}`} className="inline-flex min-h-10 items-center rounded-lg bg-success-bg px-5 text-sm font-bold text-success">Caught up</Link>}
|
||||
<button onClick={() => setEditing({ id: set.id, name: set.name, description: set.description ?? "", newCardsPerDay: set.newCardsPerDay })} className="grid h-10 w-10 place-items-center rounded-lg text-text-muted hover:bg-bg-surface-alt hover:text-text-heading" aria-label={`Edit ${set.name}`}>✎</button>
|
||||
<button onClick={() => deleteSet(set)} className="grid h-10 w-10 place-items-center rounded-lg text-text-muted hover:bg-error-bg hover:text-error" aria-label={`Delete ${set.name}`}>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<SortableContext items={set.decks.map((deck) => `set:${set.id}:deck:${deck.id}`)} strategy={verticalListSortingStrategy}>
|
||||
<SetDropZone set={set}>
|
||||
{set.decks.length === 0 ? <p className="col-span-full py-4 text-center text-sm text-text-muted">Drop flashcard decks here</p> : set.decks.map((deck) => <MembershipDeck key={deck.id} deck={deck} setId={set.id} onRemove={() => removeDeck(set, deck)} />)}
|
||||
</SetDropZone>
|
||||
</SortableContext>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<section className="mt-10 border-t border-border-light pt-8">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Source library</p>
|
||||
<h3 className="editorial-title mt-1 text-2xl text-text-heading">Flashcard decks</h3>
|
||||
<p className="mb-5 mt-1 text-sm text-text-secondary">Drag a deck into any repetition set. The original deck stays here and can be reused.</p>
|
||||
{decks.length ? <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">{decks.map((deck) => <SourceDeck key={deck.id} deck={deck} />)}</div> : <div className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-text-muted">Create flashcard decks before building a repetition set.</div>}
|
||||
</section>
|
||||
</div>
|
||||
</DndContext>
|
||||
|
||||
{editing && (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center bg-black/45 p-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md rounded-3xl border border-border bg-bg-surface p-6 shadow-[var(--shadow-modal)]">
|
||||
<h3 className="editorial-title text-2xl text-text-heading">Edit repetition set</h3>
|
||||
<label className="mt-5 block text-sm font-bold text-text-heading">Name</label>
|
||||
<input value={editing.name} onChange={(event) => setEditing({ ...editing, name: event.target.value })} className="mt-1 min-h-11 w-full rounded-lg border border-border bg-bg-surface-alt px-3 text-text-heading" />
|
||||
<label className="mt-4 block text-sm font-bold text-text-heading">Description</label>
|
||||
<textarea value={editing.description} onChange={(event) => setEditing({ ...editing, description: event.target.value })} rows={3} className="mt-1 w-full resize-none rounded-lg border border-border bg-bg-surface-alt p-3 text-text-heading" />
|
||||
<label className="mt-4 block text-sm font-bold text-text-heading">New cards per day</label>
|
||||
<input type="number" min={0} max={999} value={editing.newCardsPerDay} onChange={(event) => setEditing({ ...editing, newCardsPerDay: Number(event.target.value) })} className="mt-1 min-h-11 w-full rounded-lg border border-border bg-bg-surface-alt px-3 text-text-heading" />
|
||||
<p className="mt-1 text-xs text-text-muted">Due reviews are never capped. Set this to 0 to pause new cards.</p>
|
||||
<div className="mt-6 flex justify-end gap-2"><button onClick={() => setEditing(null)} className="min-h-10 rounded-lg border border-border px-4 font-bold text-text-heading">Cancel</button><button onClick={saveEdit} className="min-h-10 rounded-lg bg-primary px-4 font-bold text-white">Save</button></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
220
src/components/spaced-repetition/SpacedRepetitionViewer.tsx
Normal file
220
src/components/spaced-repetition/SpacedRepetitionViewer.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { SpacedRepetitionRating } from "@/lib/spacedRepetition";
|
||||
|
||||
interface StudyState {
|
||||
set: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
class: { slug: string; name: string };
|
||||
stats: {
|
||||
totalCards: number;
|
||||
reviewedCards: number;
|
||||
unseenCards: number;
|
||||
dueCards: number;
|
||||
learningAheadCards: number;
|
||||
newCardsAvailable: number;
|
||||
availableCards: number;
|
||||
nextDue: string | null;
|
||||
};
|
||||
};
|
||||
currentCard: {
|
||||
id: string;
|
||||
front: string;
|
||||
back: string;
|
||||
sourceDeckName: string;
|
||||
stateVersion: string | null;
|
||||
previews: Array<{
|
||||
rating: SpacedRepetitionRating;
|
||||
due: string;
|
||||
scheduledDays: number;
|
||||
}>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const ratingStyles: Record<SpacedRepetitionRating, string> = {
|
||||
AGAIN: "border-error/35 text-error hover:bg-error-bg",
|
||||
HARD: "border-accent/40 text-accent hover:bg-bg-surface-alt",
|
||||
GOOD: "border-success/35 text-success hover:bg-success-bg",
|
||||
EASY: "border-primary/35 text-primary hover:bg-bg-callout",
|
||||
};
|
||||
|
||||
function intervalLabel(due: string, scheduledDays: number) {
|
||||
if (scheduledDays >= 1) {
|
||||
if (scheduledDays < 30) return `${scheduledDays}d`;
|
||||
const months = Math.round(scheduledDays / 30);
|
||||
if (months < 12) return `${months}mo`;
|
||||
return `${Math.round(months / 12)}y`;
|
||||
}
|
||||
|
||||
const minutes = Math.max(1, Math.round((new Date(due).getTime() - Date.now()) / 60000));
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
return `${hours}h`;
|
||||
}
|
||||
|
||||
function dueLabel(due: string | null) {
|
||||
if (!due) return "No reviews scheduled";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(due));
|
||||
}
|
||||
|
||||
export function SpacedRepetitionViewer() {
|
||||
const { classSlug, setId } = useParams<{ classSlug: string; setId: string }>();
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<StudyState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/spaced-repetition-sets/${setId}/study`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) throw new Error("Repetition set not found");
|
||||
const nextData = (await response.json()) as StudyState;
|
||||
if (nextData.set.class.slug !== classSlug) throw new Error("Repetition set not found");
|
||||
setData(nextData);
|
||||
setFlipped(false);
|
||||
setError(null);
|
||||
} catch {
|
||||
router.push(`/${classSlug}/spaced-repetition`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [classSlug, router, setId]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => void load(), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
|
||||
const submitRating = useCallback(async (rating: SpacedRepetitionRating) => {
|
||||
if (!data?.currentCard || saving) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/spaced-repetition-sets/${setId}/reviews`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
flashcardId: data.currentCard.id,
|
||||
rating,
|
||||
expectedStateVersion: data.currentCard.stateVersion,
|
||||
}),
|
||||
});
|
||||
if (response.status === 409) {
|
||||
await load();
|
||||
setError("That review was already saved. The queue has been refreshed.");
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
throw new Error(body?.error ?? "Unable to save review");
|
||||
}
|
||||
setData(await response.json());
|
||||
window.dispatchEvent(new Event("spaced-repetition-updated"));
|
||||
setFlipped(false);
|
||||
} catch (reviewError) {
|
||||
setError(reviewError instanceof Error ? reviewError.message : "Unable to save review");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [data, load, saving, setId]);
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (!data?.currentCard || saving) return;
|
||||
if (event.key === " " || event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
setFlipped((current) => !current);
|
||||
return;
|
||||
}
|
||||
if (!flipped) return;
|
||||
const keyboardRatings: Record<string, SpacedRepetitionRating> = {
|
||||
"1": "AGAIN", "2": "HARD", "3": "GOOD", "4": "EASY",
|
||||
};
|
||||
const rating = keyboardRatings[event.key];
|
||||
if (rating) submitRating(rating);
|
||||
}
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [data, flipped, saving, submitRating]);
|
||||
|
||||
if (loading || !data) {
|
||||
return <div className="mx-auto max-w-5xl animate-subtle-pulse py-16 text-center text-text-muted">Loading review queue…</div>;
|
||||
}
|
||||
|
||||
const card = data.currentCard;
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-5xl py-1 pb-16 md:py-3">
|
||||
<header className="mb-6 rounded-2xl border border-border-light bg-bg-surface/75 p-4 shadow-[var(--shadow-card)] sm:p-5">
|
||||
<button onClick={() => router.push(`/${classSlug}/spaced-repetition`)} className="mb-2 inline-flex min-h-10 items-center gap-2 text-sm text-text-muted hover:text-text-heading"><span aria-hidden>←</span> Back to repetition sets</button>
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Spaced repetition</p><h1 className="editorial-title mt-1 text-3xl text-text-heading">{data.set.name}</h1>{data.set.description && <p className="mt-1 text-sm text-text-secondary">{data.set.description}</p>}</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<span className="rounded-full bg-error-bg px-3 py-1.5 text-error">{data.set.stats.dueCards} due</span>
|
||||
{data.set.stats.learningAheadCards > 0 && <span className="rounded-full bg-bg-callout px-3 py-1.5 text-primary">{data.set.stats.learningAheadCards} learning</span>}
|
||||
<span className="rounded-full bg-success-bg px-3 py-1.5 text-success">{data.set.stats.newCardsAvailable} new</span>
|
||||
<span className="rounded-full bg-bg-surface-alt px-3 py-1.5 text-text-muted">{data.set.stats.reviewedCards}/{data.set.stats.totalCards} introduced</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div role="alert" className="mb-5 flex items-center justify-between rounded-xl border border-error/30 bg-error-bg px-4 py-3 text-sm text-error"><span>{error}</span><button onClick={() => setError(null)} aria-label="Dismiss error">×</button></div>}
|
||||
|
||||
{!card ? (
|
||||
<div className="rounded-3xl border border-success/25 bg-bg-surface p-8 text-center shadow-[var(--shadow-card)] sm:p-12">
|
||||
<div className="mx-auto grid h-16 w-16 place-items-center rounded-full bg-success-bg text-3xl text-success">✓</div>
|
||||
<h2 className="editorial-title mt-5 text-3xl text-text-heading">You’re caught up</h2>
|
||||
<p className="mt-2 text-text-secondary">There are no due cards and today’s new-card allowance is complete.</p>
|
||||
<p className="mt-5 text-sm font-semibold text-text-muted">Next review: {dueLabel(data.set.stats.nextDue)}</p>
|
||||
<button onClick={() => router.push(`/${classSlug}/spaced-repetition`)} className="mt-7 min-h-11 rounded-xl bg-primary px-6 font-bold text-white hover:bg-primary-hover">Back to sets</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="mb-4 flex w-full items-center justify-between text-sm text-text-muted"><span>From {card.sourceDeckName}</span><span>{data.set.stats.availableCards} available</span></div>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={flipped ? "Showing card answer. Press Space to show front." : "Showing card front. Press Space to reveal answer."}
|
||||
onClick={() => !saving && setFlipped((current) => !current)}
|
||||
className="perspective-1000 relative w-full cursor-pointer select-none"
|
||||
>
|
||||
<div className={`preserve-3d relative min-h-[420px] w-full transition-transform duration-300 md:min-h-[560px] ${flipped ? "rotate-x-180" : ""}`}>
|
||||
<div className="paper-grid backface-hidden absolute inset-0 flex flex-col items-center justify-center rounded-[2rem] border border-border-light bg-bg-surface p-7 shadow-[var(--shadow-card-hover)] md:p-12">
|
||||
<span className="absolute right-5 top-4 text-xs font-bold uppercase tracking-wider text-text-muted">Front</span>
|
||||
<div className="markdown-content w-full text-center font-serif text-2xl font-semibold leading-relaxed text-text-heading md:text-4xl"><ReactMarkdown remarkPlugins={[remarkGfm]}>{card.front}</ReactMarkdown></div>
|
||||
<p className="mt-7 text-xs text-text-muted">Tap or press Space to reveal the answer</p>
|
||||
</div>
|
||||
<div className="paper-grid backface-hidden absolute inset-0 flex rotate-x-180 flex-col items-center justify-center rounded-[2rem] border border-primary/25 bg-bg-surface p-7 shadow-[var(--shadow-card-hover)] md:p-12">
|
||||
<span className="absolute right-5 top-4 text-xs font-bold uppercase tracking-wider text-text-muted">Back</span>
|
||||
<div className="markdown-content max-h-[390px] w-full overflow-y-auto text-center text-lg leading-relaxed text-text-body md:max-h-[450px] md:text-xl"><ReactMarkdown remarkPlugins={[remarkGfm]}>{card.back}</ReactMarkdown></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`mt-6 grid w-full grid-cols-2 gap-2 transition-opacity sm:grid-cols-4 ${flipped ? "opacity-100" : "pointer-events-none opacity-0"}`} aria-hidden={!flipped}>
|
||||
{card.previews.map((preview, index) => (
|
||||
<button key={preview.rating} disabled={saving || !flipped} onClick={() => submitRating(preview.rating)} className={`min-h-16 rounded-xl border-2 bg-bg-surface px-3 py-2 text-sm font-extrabold transition-colors disabled:opacity-50 ${ratingStyles[preview.rating]}`}>
|
||||
<span className="block">{preview.rating[0]}{preview.rating.slice(1).toLowerCase()}</span>
|
||||
<span className="mt-0.5 block text-xs font-semibold opacity-75">{index + 1} · {intervalLabel(preview.due, preview.scheduledDays)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-4 text-center text-xs text-text-muted">{flipped ? "Choose 1–4 based on how well you remembered" : "Space to flip"}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,14 +2,12 @@
|
|||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ClassTabs } from "./ClassTabs";
|
||||
|
||||
interface ClassHeaderProps {
|
||||
classSlug: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
export function ClassHeader({ classSlug, className }: ClassHeaderProps) {
|
||||
export function ClassHeader({ className }: ClassHeaderProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
// If we are deeper than /[classSlug]/[tab], hide this header to save space
|
||||
|
|
@ -23,21 +21,21 @@ export function ClassHeader({ classSlug, className }: ClassHeaderProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<div className="mb-8 flex flex-col gap-4 border-b border-border-light pb-7 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-text-heading mb-2 transition-colors"
|
||||
className="mb-3 inline-flex min-h-10 items-center gap-2 rounded-lg pr-3 text-xs font-bold uppercase tracking-[0.15em] text-text-muted transition-colors hover:text-primary"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
All Classes
|
||||
Library
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-text-heading">{className}</h1>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.18em] text-primary">Active class</p>
|
||||
<h1 className="editorial-title mt-1 text-4xl text-text-heading sm:text-5xl">{className}</h1>
|
||||
</div>
|
||||
<p className="max-w-sm text-sm leading-6 text-text-secondary">Choose a mode, then settle in for a focused study session.</p>
|
||||
</div>
|
||||
|
||||
<ClassTabs classSlug={classSlug} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ export function ClassTabs({ classSlug }: ClassTabsProps) {
|
|||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="border-b border-border-light mb-6">
|
||||
<nav className="flex gap-0" aria-label="Study modes">
|
||||
<div className="mb-8 border-b border-border-light">
|
||||
<nav className="flex gap-2" aria-label="Study modes">
|
||||
{STUDY_MODES.map((mode) => {
|
||||
const href = `/${classSlug}/${mode.path}`;
|
||||
const isActive = pathname === href || pathname.startsWith(href + "/");
|
||||
|
|
@ -22,7 +22,7 @@ export function ClassTabs({ classSlug }: ClassTabsProps) {
|
|||
<Link
|
||||
key={mode.key}
|
||||
href={href}
|
||||
className={`relative px-5 py-3 text-sm font-medium transition-colors duration-200 ${
|
||||
className={`relative min-h-12 px-4 py-3 text-sm font-bold transition-colors duration-200 ${
|
||||
isActive
|
||||
? "text-primary"
|
||||
: "text-text-muted hover:text-text-heading"
|
||||
|
|
@ -30,7 +30,7 @@ export function ClassTabs({ classSlug }: ClassTabsProps) {
|
|||
>
|
||||
{mode.label}
|
||||
{isActive && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full" />
|
||||
<span className="absolute bottom-0 left-2 right-2 h-[3px] rounded-t-full bg-primary" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,56 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { STUDY_MODES } from "@/config/studyModes";
|
||||
|
||||
function Mark() {
|
||||
return (
|
||||
<span className="grid h-9 w-9 place-items-center rounded-xl bg-primary text-sm font-extrabold text-white shadow-[0_8px_22px_color-mix(in_srgb,var(--theme-primary)_28%,transparent)]">
|
||||
S
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Navbar() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
const classSlug = parts[0] && parts[0] !== "shared" ? parts[0] : null;
|
||||
const [hasReadyCards, setHasReadyCards] = useState(false);
|
||||
|
||||
const refreshDueCards = useCallback(async () => {
|
||||
if (!classSlug) return;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/classes");
|
||||
if (!response.ok) return;
|
||||
const classes = await response.json() as Array<{
|
||||
slug: string;
|
||||
spacedRepetitionReadyCards: number;
|
||||
}>;
|
||||
const currentClass = classes.find((classItem) => classItem.slug === classSlug);
|
||||
setHasReadyCards((currentClass?.spacedRepetitionReadyCards ?? 0) > 0);
|
||||
} catch {
|
||||
// Keep the last known state when a background refresh fails.
|
||||
}
|
||||
}, [classSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
const initialRefresh = window.setTimeout(() => void refreshDueCards(), 0);
|
||||
const interval = window.setInterval(refreshDueCards, 60_000);
|
||||
window.addEventListener("focus", refreshDueCards);
|
||||
window.addEventListener("spaced-repetition-updated", refreshDueCards);
|
||||
return () => {
|
||||
window.clearTimeout(initialRefresh);
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener("focus", refreshDueCards);
|
||||
window.removeEventListener("spaced-repetition-updated", refreshDueCards);
|
||||
};
|
||||
}, [refreshDueCards]);
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
|
|
@ -13,40 +58,90 @@ export function Navbar() {
|
|||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="bg-bg-surface border-b border-border-light">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-14">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2.5 text-text-heading font-semibold hover:text-primary transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 text-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
Study
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<ThemeToggle />
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-sm text-text-muted hover:text-text-heading transition-colors cursor-pointer"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
const nav = (
|
||||
<>
|
||||
<Link href="/" onClick={() => setOpen(false)} className="mb-9 flex items-center gap-3">
|
||||
<Mark />
|
||||
<span>
|
||||
<span className="block text-[11px] font-bold uppercase tracking-[0.2em] text-text-muted">Your</span>
|
||||
<span className="editorial-title block text-xl leading-none text-text-heading">Study Desk</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="space-y-1" aria-label="Primary navigation">
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => setOpen(false)}
|
||||
className={`flex min-h-11 items-center gap-3 rounded-xl px-3 text-sm font-semibold transition-colors ${pathname === "/" ? "bg-primary text-white shadow-sm" : "text-text-secondary hover:bg-bg-surface-alt hover:text-text-heading"}`}
|
||||
>
|
||||
<span aria-hidden className="text-lg">⌂</span> Library
|
||||
</Link>
|
||||
{classSlug && (
|
||||
<div className="pt-6">
|
||||
<p className="mb-2 px-3 text-[10px] font-bold uppercase tracking-[0.18em] text-text-muted">Current class</p>
|
||||
{STUDY_MODES.map((mode, index) => {
|
||||
const isActive = pathname.includes(`/${mode.path}`);
|
||||
return (
|
||||
<Link
|
||||
key={mode.key}
|
||||
href={`/${classSlug}/${mode.path}`}
|
||||
onClick={() => setOpen(false)}
|
||||
className={`${index > 0 ? "mt-1 " : ""}flex min-h-11 items-center gap-3 rounded-xl px-3 text-sm font-semibold transition-colors ${isActive ? "bg-bg-callout text-primary" : "text-text-secondary hover:bg-bg-surface-alt hover:text-text-heading"}`}
|
||||
>
|
||||
<span aria-hidden>{mode.icon}</span> {mode.label}
|
||||
{mode.key === "spaced-repetition" && hasReadyCards && (
|
||||
<span
|
||||
className="ml-auto grid h-5 w-5 place-items-center rounded-full bg-error-bg text-xs font-extrabold text-error"
|
||||
title="Spaced repetition cards are ready to study"
|
||||
>
|
||||
<span aria-hidden>!</span>
|
||||
<span className="sr-only">Cards ready to study</span>
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto border-t border-border-light pt-4">
|
||||
<div className="flex items-center justify-between rounded-xl px-2 py-1.5">
|
||||
<span className="text-xs font-semibold text-text-muted">Appearance</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<button onClick={handleLogout} className="mt-1 flex min-h-11 w-full items-center gap-3 rounded-xl px-3 text-left text-sm font-semibold text-text-secondary transition-colors hover:bg-error-bg hover:text-error">
|
||||
<span aria-hidden>↗</span> Sign out
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="app-sidebar fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-border-light bg-bg-surface/88 px-4 py-5 backdrop-blur-xl md:flex">
|
||||
{nav}
|
||||
</aside>
|
||||
|
||||
<header className="app-mobile-header sticky top-0 z-40 flex h-16 items-center justify-between border-b border-border-light bg-bg-surface/88 px-4 backdrop-blur-xl md:hidden">
|
||||
<Link href="/" className="flex items-center gap-2.5">
|
||||
<Mark />
|
||||
<span className="editorial-title text-lg text-text-heading">Study Desk</span>
|
||||
</Link>
|
||||
<button onClick={() => setOpen(true)} className="grid h-11 w-11 place-items-center rounded-xl border border-border-light bg-bg-surface-alt text-xl text-text-heading" aria-label="Open navigation" aria-expanded={open}>
|
||||
☰
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 md:hidden">
|
||||
<button className="absolute inset-0 bg-black/45 backdrop-blur-sm" onClick={() => setOpen(false)} aria-label="Close navigation" />
|
||||
<aside className="animate-slide-up absolute inset-y-0 right-0 flex w-[min(86vw,21rem)] flex-col border-l border-border-light bg-bg-surface p-5 shadow-[var(--shadow-modal)]">
|
||||
<button onClick={() => setOpen(false)} className="absolute right-4 top-4 grid h-10 w-10 place-items-center rounded-xl text-xl text-text-muted hover:bg-bg-surface-alt" aria-label="Close navigation">×</button>
|
||||
{nav}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,39 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
interface ShareMenuProps {
|
||||
targetType: "DECK" | "QUIZ" | "GROUP";
|
||||
contentId: string;
|
||||
classSlug: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function ShareMenu({ targetType, contentId, classSlug }: ShareMenuProps) {
|
||||
export function ShareMenu({ targetType, contentId, classSlug, compact = false }: ShareMenuProps) {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isGroupShared, setIsGroupShared] = useState(false);
|
||||
const [groupName, setGroupName] = useState<string | null>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
const target = event.target as Node;
|
||||
if (buttonRef.current?.contains(target) || menuRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -45,8 +64,15 @@ export function ShareMenu({ targetType, contentId, classSlug }: ShareMenuProps)
|
|||
|
||||
async function handleCopy() {
|
||||
if (!token) return;
|
||||
const typeStr = targetType === "GROUP" ? "groups" : (targetType === "DECK" ? "flashcards" : "quizzes");
|
||||
const url = `${window.location.origin}/shared/${classSlug}/${typeStr}/${token}`;
|
||||
const typeStr = isGroupShared
|
||||
? "groups"
|
||||
: targetType === "GROUP"
|
||||
? "groups"
|
||||
: targetType === "DECK"
|
||||
? "flashcards"
|
||||
: "quizzes";
|
||||
const itemQuery = isGroupShared ? `?itemId=${encodeURIComponent(contentId)}` : "";
|
||||
const url = `${window.location.origin}/shared/${classSlug}/${typeStr}/${token}${itemQuery}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
|
|
@ -55,8 +81,9 @@ export function ShareMenu({ targetType, contentId, classSlug }: ShareMenuProps)
|
|||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
|
||||
className={`${compact ? "h-8 w-8 rounded-lg" : "h-10 w-10 rounded-xl"} grid place-items-center text-text-muted transition-colors hover:bg-bg-surface-alt hover:text-primary`}
|
||||
title="Share"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -67,8 +94,8 @@ export function ShareMenu({ targetType, contentId, classSlug }: ShareMenuProps)
|
|||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||
<div className="absolute right-0 mt-2 w-72 bg-bg-surface rounded-xl shadow-[var(--shadow-modal)] border border-border-light z-50 p-4 animate-slide-up origin-top-right">
|
||||
<h4 className="text-sm font-semibold text-text-heading mb-3">Public Sharing</h4>
|
||||
<div ref={menuRef} className="absolute right-0 z-50 mt-2 w-72 origin-top-right animate-slide-up rounded-2xl border border-border-light bg-bg-surface p-5 shadow-[var(--shadow-modal)]">
|
||||
<h4 className="editorial-title mb-3 text-xl text-text-heading">Public sharing</h4>
|
||||
|
||||
{loading ? (
|
||||
<div className="h-8 bg-bg-surface-alt rounded w-full animate-subtle-pulse" />
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export function ThemeToggle() {
|
|||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return <div className="w-8 h-8" />; // placeholder to prevent layout shift
|
||||
return <div className="h-10 w-10" />; // placeholder to prevent layout shift
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
|
|
@ -30,9 +30,9 @@ export function ThemeToggle() {
|
|||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-1.5 text-text-muted hover:text-text-heading transition-colors cursor-pointer rounded-lg hover:bg-bg-surface-alt flex items-center justify-center"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-border-light bg-bg-surface-alt text-text-muted transition-colors hover:border-primary/30 hover:text-primary"
|
||||
title={isDark ? "Switch to Light Mode" : "Switch to Dark Mode"}
|
||||
aria-label="Toggle Dark Mode"
|
||||
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
{isDark ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
|
|||
34
src/config/arcadeGames.ts
Normal file
34
src/config/arcadeGames.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export const ARCADE_GAMES = [
|
||||
{
|
||||
key: "connections",
|
||||
name: "Connections",
|
||||
path: "connections",
|
||||
available: true,
|
||||
estimatedMinutes: "5–10 min",
|
||||
description: "Find four hidden relationships among sixteen terms.",
|
||||
},
|
||||
{
|
||||
key: "crossword",
|
||||
name: "Crossword",
|
||||
path: "crossword",
|
||||
available: true,
|
||||
estimatedMinutes: "10–45 min",
|
||||
description: "Turn course terminology into an interlocking word puzzle.",
|
||||
},
|
||||
{
|
||||
key: "falling-blocks",
|
||||
name: "Falling Blocks",
|
||||
path: "falling-blocks",
|
||||
available: false,
|
||||
estimatedMinutes: "Coming soon",
|
||||
description: "Match prompts to answers before the board fills up.",
|
||||
},
|
||||
{
|
||||
key: "asteroid-defense",
|
||||
name: "Asteroid Defense",
|
||||
path: "asteroid-defense",
|
||||
available: false,
|
||||
estimatedMinutes: "Coming soon",
|
||||
description: "Defend the station by targeting the correct answers.",
|
||||
},
|
||||
] as const;
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
export const STUDY_MODES = [
|
||||
{ key: "flashcards", label: "Flashcards", path: "flashcards" },
|
||||
{ key: "quizzes", label: "Quizzes", path: "quizzes" },
|
||||
{ key: "flashcards", label: "Flashcards", path: "flashcards", icon: "▤" },
|
||||
{ key: "quizzes", label: "Quizzes", path: "quizzes", icon: "✓" },
|
||||
{ key: "spaced-repetition", label: "Spaced Repetition", path: "spaced-repetition", icon: "↻" },
|
||||
{ key: "arcade", label: "Arcade", path: "arcade", icon: "◆" },
|
||||
// Future modes get added here — the layout, tab bar, and auth middleware need no changes.
|
||||
] as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -72,8 +72,38 @@ export type AuthSecurity = Prisma.AuthSecurityModel
|
|||
*
|
||||
*/
|
||||
export type Setting = Prisma.SettingModel
|
||||
/**
|
||||
* Model StudyActivity
|
||||
*
|
||||
*/
|
||||
export type StudyActivity = Prisma.StudyActivityModel
|
||||
/**
|
||||
* Model ArcadePack
|
||||
*
|
||||
*/
|
||||
export type ArcadePack = Prisma.ArcadePackModel
|
||||
/**
|
||||
* Model ArcadeAttempt
|
||||
*
|
||||
*/
|
||||
export type ArcadeAttempt = Prisma.ArcadeAttemptModel
|
||||
/**
|
||||
* Model MaterialGroup
|
||||
*
|
||||
*/
|
||||
export type MaterialGroup = Prisma.MaterialGroupModel
|
||||
/**
|
||||
* Model SpacedRepetitionSet
|
||||
*
|
||||
*/
|
||||
export type SpacedRepetitionSet = Prisma.SpacedRepetitionSetModel
|
||||
/**
|
||||
* Model SpacedRepetitionSetDeck
|
||||
*
|
||||
*/
|
||||
export type SpacedRepetitionSetDeck = Prisma.SpacedRepetitionSetDeckModel
|
||||
/**
|
||||
* Model SpacedRepetitionCardState
|
||||
*
|
||||
*/
|
||||
export type SpacedRepetitionCardState = Prisma.SpacedRepetitionCardStateModel
|
||||
|
|
|
|||
|
|
@ -96,8 +96,38 @@ export type AuthSecurity = Prisma.AuthSecurityModel
|
|||
*
|
||||
*/
|
||||
export type Setting = Prisma.SettingModel
|
||||
/**
|
||||
* Model StudyActivity
|
||||
*
|
||||
*/
|
||||
export type StudyActivity = Prisma.StudyActivityModel
|
||||
/**
|
||||
* Model ArcadePack
|
||||
*
|
||||
*/
|
||||
export type ArcadePack = Prisma.ArcadePackModel
|
||||
/**
|
||||
* Model ArcadeAttempt
|
||||
*
|
||||
*/
|
||||
export type ArcadeAttempt = Prisma.ArcadeAttemptModel
|
||||
/**
|
||||
* Model MaterialGroup
|
||||
*
|
||||
*/
|
||||
export type MaterialGroup = Prisma.MaterialGroupModel
|
||||
/**
|
||||
* Model SpacedRepetitionSet
|
||||
*
|
||||
*/
|
||||
export type SpacedRepetitionSet = Prisma.SpacedRepetitionSetModel
|
||||
/**
|
||||
* Model SpacedRepetitionSetDeck
|
||||
*
|
||||
*/
|
||||
export type SpacedRepetitionSetDeck = Prisma.SpacedRepetitionSetDeckModel
|
||||
/**
|
||||
* Model SpacedRepetitionCardState
|
||||
*
|
||||
*/
|
||||
export type SpacedRepetitionCardState = Prisma.SpacedRepetitionCardStateModel
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -395,7 +395,13 @@ export const ModelName = {
|
|||
ShareLink: 'ShareLink',
|
||||
AuthSecurity: 'AuthSecurity',
|
||||
Setting: 'Setting',
|
||||
MaterialGroup: 'MaterialGroup'
|
||||
StudyActivity: 'StudyActivity',
|
||||
ArcadePack: 'ArcadePack',
|
||||
ArcadeAttempt: 'ArcadeAttempt',
|
||||
MaterialGroup: 'MaterialGroup',
|
||||
SpacedRepetitionSet: 'SpacedRepetitionSet',
|
||||
SpacedRepetitionSetDeck: 'SpacedRepetitionSetDeck',
|
||||
SpacedRepetitionCardState: 'SpacedRepetitionCardState'
|
||||
} as const
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
|
|
@ -411,7 +417,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||
omit: GlobalOmitOptions
|
||||
}
|
||||
meta: {
|
||||
modelProps: "class" | "deck" | "flashcard" | "quizSet" | "question" | "answerOption" | "studyProgress" | "quizAttempt" | "shareLink" | "authSecurity" | "setting" | "materialGroup"
|
||||
modelProps: "class" | "deck" | "flashcard" | "quizSet" | "question" | "answerOption" | "studyProgress" | "quizAttempt" | "shareLink" | "authSecurity" | "setting" | "studyActivity" | "arcadePack" | "arcadeAttempt" | "materialGroup" | "spacedRepetitionSet" | "spacedRepetitionSetDeck" | "spacedRepetitionCardState"
|
||||
txIsolationLevel: TransactionIsolationLevel
|
||||
}
|
||||
model: {
|
||||
|
|
@ -1229,6 +1235,228 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||
}
|
||||
}
|
||||
}
|
||||
StudyActivity: {
|
||||
payload: Prisma.$StudyActivityPayload<ExtArgs>
|
||||
fields: Prisma.StudyActivityFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.StudyActivityFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.StudyActivityFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.StudyActivityFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.StudyActivityFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.StudyActivityFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.StudyActivityCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.StudyActivityCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
createManyAndReturn: {
|
||||
args: Prisma.StudyActivityCreateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>[]
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.StudyActivityDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.StudyActivityUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.StudyActivityDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.StudyActivityUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateManyAndReturn: {
|
||||
args: Prisma.StudyActivityUpdateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>[]
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.StudyActivityUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.StudyActivityAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateStudyActivity>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.StudyActivityGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.StudyActivityGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.StudyActivityCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.StudyActivityCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
ArcadePack: {
|
||||
payload: Prisma.$ArcadePackPayload<ExtArgs>
|
||||
fields: Prisma.ArcadePackFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.ArcadePackFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.ArcadePackFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.ArcadePackFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.ArcadePackFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.ArcadePackFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.ArcadePackCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.ArcadePackCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
createManyAndReturn: {
|
||||
args: Prisma.ArcadePackCreateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>[]
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.ArcadePackDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.ArcadePackUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.ArcadePackDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.ArcadePackUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateManyAndReturn: {
|
||||
args: Prisma.ArcadePackUpdateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>[]
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.ArcadePackUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.ArcadePackAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateArcadePack>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.ArcadePackGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.ArcadePackGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.ArcadePackCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.ArcadePackCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
ArcadeAttempt: {
|
||||
payload: Prisma.$ArcadeAttemptPayload<ExtArgs>
|
||||
fields: Prisma.ArcadeAttemptFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.ArcadeAttemptFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.ArcadeAttemptFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.ArcadeAttemptFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.ArcadeAttemptFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.ArcadeAttemptFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.ArcadeAttemptCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.ArcadeAttemptCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
createManyAndReturn: {
|
||||
args: Prisma.ArcadeAttemptCreateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>[]
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.ArcadeAttemptDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.ArcadeAttemptUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.ArcadeAttemptDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.ArcadeAttemptUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateManyAndReturn: {
|
||||
args: Prisma.ArcadeAttemptUpdateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>[]
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.ArcadeAttemptUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.ArcadeAttemptAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateArcadeAttempt>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.ArcadeAttemptGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.ArcadeAttemptGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.ArcadeAttemptCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.ArcadeAttemptCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
MaterialGroup: {
|
||||
payload: Prisma.$MaterialGroupPayload<ExtArgs>
|
||||
fields: Prisma.MaterialGroupFieldRefs
|
||||
|
|
@ -1303,6 +1531,228 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||
}
|
||||
}
|
||||
}
|
||||
SpacedRepetitionSet: {
|
||||
payload: Prisma.$SpacedRepetitionSetPayload<ExtArgs>
|
||||
fields: Prisma.SpacedRepetitionSetFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.SpacedRepetitionSetFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.SpacedRepetitionSetFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.SpacedRepetitionSetFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.SpacedRepetitionSetFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.SpacedRepetitionSetFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.SpacedRepetitionSetCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.SpacedRepetitionSetCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
createManyAndReturn: {
|
||||
args: Prisma.SpacedRepetitionSetCreateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>[]
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.SpacedRepetitionSetDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.SpacedRepetitionSetUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.SpacedRepetitionSetDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.SpacedRepetitionSetUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateManyAndReturn: {
|
||||
args: Prisma.SpacedRepetitionSetUpdateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>[]
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.SpacedRepetitionSetUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.SpacedRepetitionSetAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateSpacedRepetitionSet>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.SpacedRepetitionSetGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.SpacedRepetitionSetGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.SpacedRepetitionSetCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.SpacedRepetitionSetCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
SpacedRepetitionSetDeck: {
|
||||
payload: Prisma.$SpacedRepetitionSetDeckPayload<ExtArgs>
|
||||
fields: Prisma.SpacedRepetitionSetDeckFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.SpacedRepetitionSetDeckFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.SpacedRepetitionSetDeckFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.SpacedRepetitionSetDeckFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.SpacedRepetitionSetDeckFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.SpacedRepetitionSetDeckFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.SpacedRepetitionSetDeckCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.SpacedRepetitionSetDeckCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
createManyAndReturn: {
|
||||
args: Prisma.SpacedRepetitionSetDeckCreateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>[]
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.SpacedRepetitionSetDeckDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.SpacedRepetitionSetDeckUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.SpacedRepetitionSetDeckDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.SpacedRepetitionSetDeckUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateManyAndReturn: {
|
||||
args: Prisma.SpacedRepetitionSetDeckUpdateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>[]
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.SpacedRepetitionSetDeckUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.SpacedRepetitionSetDeckAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateSpacedRepetitionSetDeck>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.SpacedRepetitionSetDeckGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.SpacedRepetitionSetDeckGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.SpacedRepetitionSetDeckCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.SpacedRepetitionSetDeckCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
SpacedRepetitionCardState: {
|
||||
payload: Prisma.$SpacedRepetitionCardStatePayload<ExtArgs>
|
||||
fields: Prisma.SpacedRepetitionCardStateFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.SpacedRepetitionCardStateFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.SpacedRepetitionCardStateFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.SpacedRepetitionCardStateFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.SpacedRepetitionCardStateFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.SpacedRepetitionCardStateFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.SpacedRepetitionCardStateCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.SpacedRepetitionCardStateCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
createManyAndReturn: {
|
||||
args: Prisma.SpacedRepetitionCardStateCreateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>[]
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.SpacedRepetitionCardStateDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.SpacedRepetitionCardStateUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.SpacedRepetitionCardStateDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.SpacedRepetitionCardStateUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateManyAndReturn: {
|
||||
args: Prisma.SpacedRepetitionCardStateUpdateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>[]
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.SpacedRepetitionCardStateUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.SpacedRepetitionCardStateAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateSpacedRepetitionCardState>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.SpacedRepetitionCardStateGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.SpacedRepetitionCardStateGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.SpacedRepetitionCardStateCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.SpacedRepetitionCardStateCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} & {
|
||||
other: {
|
||||
|
|
@ -1470,6 +1920,52 @@ export const SettingScalarFieldEnum = {
|
|||
export type SettingScalarFieldEnum = (typeof SettingScalarFieldEnum)[keyof typeof SettingScalarFieldEnum]
|
||||
|
||||
|
||||
export const StudyActivityScalarFieldEnum = {
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
occurredAt: 'occurredAt'
|
||||
} as const
|
||||
|
||||
export type StudyActivityScalarFieldEnum = (typeof StudyActivityScalarFieldEnum)[keyof typeof StudyActivityScalarFieldEnum]
|
||||
|
||||
|
||||
export const ArcadePackScalarFieldEnum = {
|
||||
id: 'id',
|
||||
classId: 'classId',
|
||||
gameType: 'gameType',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
schemaVersion: 'schemaVersion',
|
||||
sourceJson: 'sourceJson',
|
||||
normalizedJson: 'normalizedJson',
|
||||
validationReportJson: 'validationReportJson',
|
||||
sortOrder: 'sortOrder',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type ArcadePackScalarFieldEnum = (typeof ArcadePackScalarFieldEnum)[keyof typeof ArcadePackScalarFieldEnum]
|
||||
|
||||
|
||||
export const ArcadeAttemptScalarFieldEnum = {
|
||||
id: 'id',
|
||||
arcadePackId: 'arcadePackId',
|
||||
mode: 'mode',
|
||||
score: 'score',
|
||||
maxScore: 'maxScore',
|
||||
accuracy: 'accuracy',
|
||||
durationSeconds: 'durationSeconds',
|
||||
mistakes: 'mistakes',
|
||||
hintsUsed: 'hintsUsed',
|
||||
settingsJson: 'settingsJson',
|
||||
resultsJson: 'resultsJson',
|
||||
seed: 'seed',
|
||||
completedAt: 'completedAt'
|
||||
} as const
|
||||
|
||||
export type ArcadeAttemptScalarFieldEnum = (typeof ArcadeAttemptScalarFieldEnum)[keyof typeof ArcadeAttemptScalarFieldEnum]
|
||||
|
||||
|
||||
export const MaterialGroupScalarFieldEnum = {
|
||||
id: 'id',
|
||||
classId: 'classId',
|
||||
|
|
@ -1482,6 +1978,52 @@ export const MaterialGroupScalarFieldEnum = {
|
|||
export type MaterialGroupScalarFieldEnum = (typeof MaterialGroupScalarFieldEnum)[keyof typeof MaterialGroupScalarFieldEnum]
|
||||
|
||||
|
||||
export const SpacedRepetitionSetScalarFieldEnum = {
|
||||
id: 'id',
|
||||
classId: 'classId',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
newCardsPerDay: 'newCardsPerDay',
|
||||
sortOrder: 'sortOrder',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type SpacedRepetitionSetScalarFieldEnum = (typeof SpacedRepetitionSetScalarFieldEnum)[keyof typeof SpacedRepetitionSetScalarFieldEnum]
|
||||
|
||||
|
||||
export const SpacedRepetitionSetDeckScalarFieldEnum = {
|
||||
setId: 'setId',
|
||||
deckId: 'deckId',
|
||||
sortOrder: 'sortOrder',
|
||||
addedAt: 'addedAt'
|
||||
} as const
|
||||
|
||||
export type SpacedRepetitionSetDeckScalarFieldEnum = (typeof SpacedRepetitionSetDeckScalarFieldEnum)[keyof typeof SpacedRepetitionSetDeckScalarFieldEnum]
|
||||
|
||||
|
||||
export const SpacedRepetitionCardStateScalarFieldEnum = {
|
||||
id: 'id',
|
||||
setId: 'setId',
|
||||
flashcardId: 'flashcardId',
|
||||
due: 'due',
|
||||
stability: 'stability',
|
||||
difficulty: 'difficulty',
|
||||
elapsedDays: 'elapsedDays',
|
||||
scheduledDays: 'scheduledDays',
|
||||
learningSteps: 'learningSteps',
|
||||
reps: 'reps',
|
||||
lapses: 'lapses',
|
||||
state: 'state',
|
||||
firstReviewedAt: 'firstReviewedAt',
|
||||
lastReview: 'lastReview',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type SpacedRepetitionCardStateScalarFieldEnum = (typeof SpacedRepetitionCardStateScalarFieldEnum)[keyof typeof SpacedRepetitionCardStateScalarFieldEnum]
|
||||
|
||||
|
||||
export const SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
|
|
@ -1659,7 +2201,13 @@ export type GlobalOmitConfig = {
|
|||
shareLink?: Prisma.ShareLinkOmit
|
||||
authSecurity?: Prisma.AuthSecurityOmit
|
||||
setting?: Prisma.SettingOmit
|
||||
studyActivity?: Prisma.StudyActivityOmit
|
||||
arcadePack?: Prisma.ArcadePackOmit
|
||||
arcadeAttempt?: Prisma.ArcadeAttemptOmit
|
||||
materialGroup?: Prisma.MaterialGroupOmit
|
||||
spacedRepetitionSet?: Prisma.SpacedRepetitionSetOmit
|
||||
spacedRepetitionSetDeck?: Prisma.SpacedRepetitionSetDeckOmit
|
||||
spacedRepetitionCardState?: Prisma.SpacedRepetitionCardStateOmit
|
||||
}
|
||||
|
||||
/* Types for Logging */
|
||||
|
|
|
|||
|
|
@ -62,7 +62,13 @@ export const ModelName = {
|
|||
ShareLink: 'ShareLink',
|
||||
AuthSecurity: 'AuthSecurity',
|
||||
Setting: 'Setting',
|
||||
MaterialGroup: 'MaterialGroup'
|
||||
StudyActivity: 'StudyActivity',
|
||||
ArcadePack: 'ArcadePack',
|
||||
ArcadeAttempt: 'ArcadeAttempt',
|
||||
MaterialGroup: 'MaterialGroup',
|
||||
SpacedRepetitionSet: 'SpacedRepetitionSet',
|
||||
SpacedRepetitionSetDeck: 'SpacedRepetitionSetDeck',
|
||||
SpacedRepetitionCardState: 'SpacedRepetitionCardState'
|
||||
} as const
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
|
|
@ -209,6 +215,52 @@ export const SettingScalarFieldEnum = {
|
|||
export type SettingScalarFieldEnum = (typeof SettingScalarFieldEnum)[keyof typeof SettingScalarFieldEnum]
|
||||
|
||||
|
||||
export const StudyActivityScalarFieldEnum = {
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
occurredAt: 'occurredAt'
|
||||
} as const
|
||||
|
||||
export type StudyActivityScalarFieldEnum = (typeof StudyActivityScalarFieldEnum)[keyof typeof StudyActivityScalarFieldEnum]
|
||||
|
||||
|
||||
export const ArcadePackScalarFieldEnum = {
|
||||
id: 'id',
|
||||
classId: 'classId',
|
||||
gameType: 'gameType',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
schemaVersion: 'schemaVersion',
|
||||
sourceJson: 'sourceJson',
|
||||
normalizedJson: 'normalizedJson',
|
||||
validationReportJson: 'validationReportJson',
|
||||
sortOrder: 'sortOrder',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type ArcadePackScalarFieldEnum = (typeof ArcadePackScalarFieldEnum)[keyof typeof ArcadePackScalarFieldEnum]
|
||||
|
||||
|
||||
export const ArcadeAttemptScalarFieldEnum = {
|
||||
id: 'id',
|
||||
arcadePackId: 'arcadePackId',
|
||||
mode: 'mode',
|
||||
score: 'score',
|
||||
maxScore: 'maxScore',
|
||||
accuracy: 'accuracy',
|
||||
durationSeconds: 'durationSeconds',
|
||||
mistakes: 'mistakes',
|
||||
hintsUsed: 'hintsUsed',
|
||||
settingsJson: 'settingsJson',
|
||||
resultsJson: 'resultsJson',
|
||||
seed: 'seed',
|
||||
completedAt: 'completedAt'
|
||||
} as const
|
||||
|
||||
export type ArcadeAttemptScalarFieldEnum = (typeof ArcadeAttemptScalarFieldEnum)[keyof typeof ArcadeAttemptScalarFieldEnum]
|
||||
|
||||
|
||||
export const MaterialGroupScalarFieldEnum = {
|
||||
id: 'id',
|
||||
classId: 'classId',
|
||||
|
|
@ -221,6 +273,52 @@ export const MaterialGroupScalarFieldEnum = {
|
|||
export type MaterialGroupScalarFieldEnum = (typeof MaterialGroupScalarFieldEnum)[keyof typeof MaterialGroupScalarFieldEnum]
|
||||
|
||||
|
||||
export const SpacedRepetitionSetScalarFieldEnum = {
|
||||
id: 'id',
|
||||
classId: 'classId',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
newCardsPerDay: 'newCardsPerDay',
|
||||
sortOrder: 'sortOrder',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type SpacedRepetitionSetScalarFieldEnum = (typeof SpacedRepetitionSetScalarFieldEnum)[keyof typeof SpacedRepetitionSetScalarFieldEnum]
|
||||
|
||||
|
||||
export const SpacedRepetitionSetDeckScalarFieldEnum = {
|
||||
setId: 'setId',
|
||||
deckId: 'deckId',
|
||||
sortOrder: 'sortOrder',
|
||||
addedAt: 'addedAt'
|
||||
} as const
|
||||
|
||||
export type SpacedRepetitionSetDeckScalarFieldEnum = (typeof SpacedRepetitionSetDeckScalarFieldEnum)[keyof typeof SpacedRepetitionSetDeckScalarFieldEnum]
|
||||
|
||||
|
||||
export const SpacedRepetitionCardStateScalarFieldEnum = {
|
||||
id: 'id',
|
||||
setId: 'setId',
|
||||
flashcardId: 'flashcardId',
|
||||
due: 'due',
|
||||
stability: 'stability',
|
||||
difficulty: 'difficulty',
|
||||
elapsedDays: 'elapsedDays',
|
||||
scheduledDays: 'scheduledDays',
|
||||
learningSteps: 'learningSteps',
|
||||
reps: 'reps',
|
||||
lapses: 'lapses',
|
||||
state: 'state',
|
||||
firstReviewedAt: 'firstReviewedAt',
|
||||
lastReview: 'lastReview',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type SpacedRepetitionCardStateScalarFieldEnum = (typeof SpacedRepetitionCardStateScalarFieldEnum)[keyof typeof SpacedRepetitionCardStateScalarFieldEnum]
|
||||
|
||||
|
||||
export const SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
|
|
|
|||
|
|
@ -19,5 +19,11 @@ export type * from './models/QuizAttempt'
|
|||
export type * from './models/ShareLink'
|
||||
export type * from './models/AuthSecurity'
|
||||
export type * from './models/Setting'
|
||||
export type * from './models/StudyActivity'
|
||||
export type * from './models/ArcadePack'
|
||||
export type * from './models/ArcadeAttempt'
|
||||
export type * from './models/MaterialGroup'
|
||||
export type * from './models/SpacedRepetitionSet'
|
||||
export type * from './models/SpacedRepetitionSetDeck'
|
||||
export type * from './models/SpacedRepetitionCardState'
|
||||
export type * from './commonInputTypes'
|
||||
1696
src/generated/prisma/models/ArcadeAttempt.ts
Normal file
1696
src/generated/prisma/models/ArcadeAttempt.ts
Normal file
File diff suppressed because it is too large
Load diff
1802
src/generated/prisma/models/ArcadePack.ts
Normal file
1802
src/generated/prisma/models/ArcadePack.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -219,6 +219,8 @@ export type ClassWhereInput = {
|
|||
decks?: Prisma.DeckListRelationFilter
|
||||
quizSets?: Prisma.QuizSetListRelationFilter
|
||||
materialGroups?: Prisma.MaterialGroupListRelationFilter
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetListRelationFilter
|
||||
arcadePacks?: Prisma.ArcadePackListRelationFilter
|
||||
}
|
||||
|
||||
export type ClassOrderByWithRelationInput = {
|
||||
|
|
@ -230,6 +232,8 @@ export type ClassOrderByWithRelationInput = {
|
|||
decks?: Prisma.DeckOrderByRelationAggregateInput
|
||||
quizSets?: Prisma.QuizSetOrderByRelationAggregateInput
|
||||
materialGroups?: Prisma.MaterialGroupOrderByRelationAggregateInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetOrderByRelationAggregateInput
|
||||
arcadePacks?: Prisma.ArcadePackOrderByRelationAggregateInput
|
||||
}
|
||||
|
||||
export type ClassWhereUniqueInput = Prisma.AtLeast<{
|
||||
|
|
@ -244,6 +248,8 @@ export type ClassWhereUniqueInput = Prisma.AtLeast<{
|
|||
decks?: Prisma.DeckListRelationFilter
|
||||
quizSets?: Prisma.QuizSetListRelationFilter
|
||||
materialGroups?: Prisma.MaterialGroupListRelationFilter
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetListRelationFilter
|
||||
arcadePacks?: Prisma.ArcadePackListRelationFilter
|
||||
}, "id" | "slug">
|
||||
|
||||
export type ClassOrderByWithAggregationInput = {
|
||||
|
|
@ -279,6 +285,8 @@ export type ClassCreateInput = {
|
|||
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
|
||||
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
|
||||
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
|
||||
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
|
||||
}
|
||||
|
||||
export type ClassUncheckedCreateInput = {
|
||||
|
|
@ -290,6 +298,8 @@ export type ClassUncheckedCreateInput = {
|
|||
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
|
||||
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
|
||||
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
|
||||
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
|
||||
}
|
||||
|
||||
export type ClassUpdateInput = {
|
||||
|
|
@ -301,6 +311,8 @@ export type ClassUpdateInput = {
|
|||
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
|
||||
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
|
||||
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
|
||||
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
|
||||
}
|
||||
|
||||
export type ClassUncheckedUpdateInput = {
|
||||
|
|
@ -312,6 +324,8 @@ export type ClassUncheckedUpdateInput = {
|
|||
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
|
||||
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
|
||||
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
|
||||
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
|
||||
}
|
||||
|
||||
export type ClassCreateManyInput = {
|
||||
|
|
@ -419,6 +433,20 @@ export type ClassUpdateOneRequiredWithoutQuizSetsNestedInput = {
|
|||
update?: Prisma.XOR<Prisma.XOR<Prisma.ClassUpdateToOneWithWhereWithoutQuizSetsInput, Prisma.ClassUpdateWithoutQuizSetsInput>, Prisma.ClassUncheckedUpdateWithoutQuizSetsInput>
|
||||
}
|
||||
|
||||
export type ClassCreateNestedOneWithoutArcadePacksInput = {
|
||||
create?: Prisma.XOR<Prisma.ClassCreateWithoutArcadePacksInput, Prisma.ClassUncheckedCreateWithoutArcadePacksInput>
|
||||
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutArcadePacksInput
|
||||
connect?: Prisma.ClassWhereUniqueInput
|
||||
}
|
||||
|
||||
export type ClassUpdateOneRequiredWithoutArcadePacksNestedInput = {
|
||||
create?: Prisma.XOR<Prisma.ClassCreateWithoutArcadePacksInput, Prisma.ClassUncheckedCreateWithoutArcadePacksInput>
|
||||
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutArcadePacksInput
|
||||
upsert?: Prisma.ClassUpsertWithoutArcadePacksInput
|
||||
connect?: Prisma.ClassWhereUniqueInput
|
||||
update?: Prisma.XOR<Prisma.XOR<Prisma.ClassUpdateToOneWithWhereWithoutArcadePacksInput, Prisma.ClassUpdateWithoutArcadePacksInput>, Prisma.ClassUncheckedUpdateWithoutArcadePacksInput>
|
||||
}
|
||||
|
||||
export type ClassCreateNestedOneWithoutMaterialGroupsInput = {
|
||||
create?: Prisma.XOR<Prisma.ClassCreateWithoutMaterialGroupsInput, Prisma.ClassUncheckedCreateWithoutMaterialGroupsInput>
|
||||
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutMaterialGroupsInput
|
||||
|
|
@ -433,6 +461,20 @@ export type ClassUpdateOneRequiredWithoutMaterialGroupsNestedInput = {
|
|||
update?: Prisma.XOR<Prisma.XOR<Prisma.ClassUpdateToOneWithWhereWithoutMaterialGroupsInput, Prisma.ClassUpdateWithoutMaterialGroupsInput>, Prisma.ClassUncheckedUpdateWithoutMaterialGroupsInput>
|
||||
}
|
||||
|
||||
export type ClassCreateNestedOneWithoutSpacedRepetitionSetsInput = {
|
||||
create?: Prisma.XOR<Prisma.ClassCreateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedCreateWithoutSpacedRepetitionSetsInput>
|
||||
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutSpacedRepetitionSetsInput
|
||||
connect?: Prisma.ClassWhereUniqueInput
|
||||
}
|
||||
|
||||
export type ClassUpdateOneRequiredWithoutSpacedRepetitionSetsNestedInput = {
|
||||
create?: Prisma.XOR<Prisma.ClassCreateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedCreateWithoutSpacedRepetitionSetsInput>
|
||||
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutSpacedRepetitionSetsInput
|
||||
upsert?: Prisma.ClassUpsertWithoutSpacedRepetitionSetsInput
|
||||
connect?: Prisma.ClassWhereUniqueInput
|
||||
update?: Prisma.XOR<Prisma.XOR<Prisma.ClassUpdateToOneWithWhereWithoutSpacedRepetitionSetsInput, Prisma.ClassUpdateWithoutSpacedRepetitionSetsInput>, Prisma.ClassUncheckedUpdateWithoutSpacedRepetitionSetsInput>
|
||||
}
|
||||
|
||||
export type ClassCreateWithoutDecksInput = {
|
||||
id?: string
|
||||
slug: string
|
||||
|
|
@ -441,6 +483,8 @@ export type ClassCreateWithoutDecksInput = {
|
|||
createdAt?: Date | string
|
||||
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
|
||||
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
|
||||
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
|
||||
}
|
||||
|
||||
export type ClassUncheckedCreateWithoutDecksInput = {
|
||||
|
|
@ -451,6 +495,8 @@ export type ClassUncheckedCreateWithoutDecksInput = {
|
|||
createdAt?: Date | string
|
||||
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
|
||||
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
|
||||
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
|
||||
}
|
||||
|
||||
export type ClassCreateOrConnectWithoutDecksInput = {
|
||||
|
|
@ -477,6 +523,8 @@ export type ClassUpdateWithoutDecksInput = {
|
|||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
|
||||
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
|
||||
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
|
||||
}
|
||||
|
||||
export type ClassUncheckedUpdateWithoutDecksInput = {
|
||||
|
|
@ -487,6 +535,8 @@ export type ClassUncheckedUpdateWithoutDecksInput = {
|
|||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
|
||||
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
|
||||
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
|
||||
}
|
||||
|
||||
export type ClassCreateWithoutQuizSetsInput = {
|
||||
|
|
@ -497,6 +547,8 @@ export type ClassCreateWithoutQuizSetsInput = {
|
|||
createdAt?: Date | string
|
||||
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
|
||||
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
|
||||
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
|
||||
}
|
||||
|
||||
export type ClassUncheckedCreateWithoutQuizSetsInput = {
|
||||
|
|
@ -507,6 +559,8 @@ export type ClassUncheckedCreateWithoutQuizSetsInput = {
|
|||
createdAt?: Date | string
|
||||
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
|
||||
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
|
||||
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
|
||||
}
|
||||
|
||||
export type ClassCreateOrConnectWithoutQuizSetsInput = {
|
||||
|
|
@ -533,6 +587,8 @@ export type ClassUpdateWithoutQuizSetsInput = {
|
|||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
|
||||
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
|
||||
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
|
||||
}
|
||||
|
||||
export type ClassUncheckedUpdateWithoutQuizSetsInput = {
|
||||
|
|
@ -543,6 +599,72 @@ export type ClassUncheckedUpdateWithoutQuizSetsInput = {
|
|||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
|
||||
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
|
||||
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
|
||||
}
|
||||
|
||||
export type ClassCreateWithoutArcadePacksInput = {
|
||||
id?: string
|
||||
slug: string
|
||||
name: string
|
||||
sortOrder?: number
|
||||
createdAt?: Date | string
|
||||
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
|
||||
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
|
||||
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
|
||||
}
|
||||
|
||||
export type ClassUncheckedCreateWithoutArcadePacksInput = {
|
||||
id?: string
|
||||
slug: string
|
||||
name: string
|
||||
sortOrder?: number
|
||||
createdAt?: Date | string
|
||||
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
|
||||
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
|
||||
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
|
||||
}
|
||||
|
||||
export type ClassCreateOrConnectWithoutArcadePacksInput = {
|
||||
where: Prisma.ClassWhereUniqueInput
|
||||
create: Prisma.XOR<Prisma.ClassCreateWithoutArcadePacksInput, Prisma.ClassUncheckedCreateWithoutArcadePacksInput>
|
||||
}
|
||||
|
||||
export type ClassUpsertWithoutArcadePacksInput = {
|
||||
update: Prisma.XOR<Prisma.ClassUpdateWithoutArcadePacksInput, Prisma.ClassUncheckedUpdateWithoutArcadePacksInput>
|
||||
create: Prisma.XOR<Prisma.ClassCreateWithoutArcadePacksInput, Prisma.ClassUncheckedCreateWithoutArcadePacksInput>
|
||||
where?: Prisma.ClassWhereInput
|
||||
}
|
||||
|
||||
export type ClassUpdateToOneWithWhereWithoutArcadePacksInput = {
|
||||
where?: Prisma.ClassWhereInput
|
||||
data: Prisma.XOR<Prisma.ClassUpdateWithoutArcadePacksInput, Prisma.ClassUncheckedUpdateWithoutArcadePacksInput>
|
||||
}
|
||||
|
||||
export type ClassUpdateWithoutArcadePacksInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
slug?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
|
||||
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
|
||||
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
|
||||
}
|
||||
|
||||
export type ClassUncheckedUpdateWithoutArcadePacksInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
slug?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
|
||||
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
|
||||
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
|
||||
}
|
||||
|
||||
export type ClassCreateWithoutMaterialGroupsInput = {
|
||||
|
|
@ -553,6 +675,8 @@ export type ClassCreateWithoutMaterialGroupsInput = {
|
|||
createdAt?: Date | string
|
||||
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
|
||||
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
|
||||
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
|
||||
}
|
||||
|
||||
export type ClassUncheckedCreateWithoutMaterialGroupsInput = {
|
||||
|
|
@ -563,6 +687,8 @@ export type ClassUncheckedCreateWithoutMaterialGroupsInput = {
|
|||
createdAt?: Date | string
|
||||
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
|
||||
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
|
||||
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
|
||||
}
|
||||
|
||||
export type ClassCreateOrConnectWithoutMaterialGroupsInput = {
|
||||
|
|
@ -589,6 +715,8 @@ export type ClassUpdateWithoutMaterialGroupsInput = {
|
|||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
|
||||
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
|
||||
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
|
||||
}
|
||||
|
||||
export type ClassUncheckedUpdateWithoutMaterialGroupsInput = {
|
||||
|
|
@ -599,6 +727,72 @@ export type ClassUncheckedUpdateWithoutMaterialGroupsInput = {
|
|||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
|
||||
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
|
||||
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
|
||||
}
|
||||
|
||||
export type ClassCreateWithoutSpacedRepetitionSetsInput = {
|
||||
id?: string
|
||||
slug: string
|
||||
name: string
|
||||
sortOrder?: number
|
||||
createdAt?: Date | string
|
||||
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
|
||||
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
|
||||
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
|
||||
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
|
||||
}
|
||||
|
||||
export type ClassUncheckedCreateWithoutSpacedRepetitionSetsInput = {
|
||||
id?: string
|
||||
slug: string
|
||||
name: string
|
||||
sortOrder?: number
|
||||
createdAt?: Date | string
|
||||
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
|
||||
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
|
||||
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
|
||||
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
|
||||
}
|
||||
|
||||
export type ClassCreateOrConnectWithoutSpacedRepetitionSetsInput = {
|
||||
where: Prisma.ClassWhereUniqueInput
|
||||
create: Prisma.XOR<Prisma.ClassCreateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedCreateWithoutSpacedRepetitionSetsInput>
|
||||
}
|
||||
|
||||
export type ClassUpsertWithoutSpacedRepetitionSetsInput = {
|
||||
update: Prisma.XOR<Prisma.ClassUpdateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedUpdateWithoutSpacedRepetitionSetsInput>
|
||||
create: Prisma.XOR<Prisma.ClassCreateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedCreateWithoutSpacedRepetitionSetsInput>
|
||||
where?: Prisma.ClassWhereInput
|
||||
}
|
||||
|
||||
export type ClassUpdateToOneWithWhereWithoutSpacedRepetitionSetsInput = {
|
||||
where?: Prisma.ClassWhereInput
|
||||
data: Prisma.XOR<Prisma.ClassUpdateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedUpdateWithoutSpacedRepetitionSetsInput>
|
||||
}
|
||||
|
||||
export type ClassUpdateWithoutSpacedRepetitionSetsInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
slug?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
|
||||
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
|
||||
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
|
||||
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
|
||||
}
|
||||
|
||||
export type ClassUncheckedUpdateWithoutSpacedRepetitionSetsInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
slug?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
|
||||
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
|
||||
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
|
||||
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -610,12 +804,16 @@ export type ClassCountOutputType = {
|
|||
decks: number
|
||||
quizSets: number
|
||||
materialGroups: number
|
||||
spacedRepetitionSets: number
|
||||
arcadePacks: number
|
||||
}
|
||||
|
||||
export type ClassCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
decks?: boolean | ClassCountOutputTypeCountDecksArgs
|
||||
quizSets?: boolean | ClassCountOutputTypeCountQuizSetsArgs
|
||||
materialGroups?: boolean | ClassCountOutputTypeCountMaterialGroupsArgs
|
||||
spacedRepetitionSets?: boolean | ClassCountOutputTypeCountSpacedRepetitionSetsArgs
|
||||
arcadePacks?: boolean | ClassCountOutputTypeCountArcadePacksArgs
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -649,6 +847,20 @@ export type ClassCountOutputTypeCountMaterialGroupsArgs<ExtArgs extends runtime.
|
|||
where?: Prisma.MaterialGroupWhereInput
|
||||
}
|
||||
|
||||
/**
|
||||
* ClassCountOutputType without action
|
||||
*/
|
||||
export type ClassCountOutputTypeCountSpacedRepetitionSetsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
where?: Prisma.SpacedRepetitionSetWhereInput
|
||||
}
|
||||
|
||||
/**
|
||||
* ClassCountOutputType without action
|
||||
*/
|
||||
export type ClassCountOutputTypeCountArcadePacksArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
where?: Prisma.ArcadePackWhereInput
|
||||
}
|
||||
|
||||
|
||||
export type ClassSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||
id?: boolean
|
||||
|
|
@ -659,6 +871,8 @@ export type ClassSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
|||
decks?: boolean | Prisma.Class$decksArgs<ExtArgs>
|
||||
quizSets?: boolean | Prisma.Class$quizSetsArgs<ExtArgs>
|
||||
materialGroups?: boolean | Prisma.Class$materialGroupsArgs<ExtArgs>
|
||||
spacedRepetitionSets?: boolean | Prisma.Class$spacedRepetitionSetsArgs<ExtArgs>
|
||||
arcadePacks?: boolean | Prisma.Class$arcadePacksArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.ClassCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}, ExtArgs["result"]["class"]>
|
||||
|
||||
|
|
@ -691,6 +905,8 @@ export type ClassInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
|||
decks?: boolean | Prisma.Class$decksArgs<ExtArgs>
|
||||
quizSets?: boolean | Prisma.Class$quizSetsArgs<ExtArgs>
|
||||
materialGroups?: boolean | Prisma.Class$materialGroupsArgs<ExtArgs>
|
||||
spacedRepetitionSets?: boolean | Prisma.Class$spacedRepetitionSetsArgs<ExtArgs>
|
||||
arcadePacks?: boolean | Prisma.Class$arcadePacksArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.ClassCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}
|
||||
export type ClassIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
|
||||
|
|
@ -702,6 +918,8 @@ export type $ClassPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs
|
|||
decks: Prisma.$DeckPayload<ExtArgs>[]
|
||||
quizSets: Prisma.$QuizSetPayload<ExtArgs>[]
|
||||
materialGroups: Prisma.$MaterialGroupPayload<ExtArgs>[]
|
||||
spacedRepetitionSets: Prisma.$SpacedRepetitionSetPayload<ExtArgs>[]
|
||||
arcadePacks: Prisma.$ArcadePackPayload<ExtArgs>[]
|
||||
}
|
||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||
id: string
|
||||
|
|
@ -1106,6 +1324,8 @@ export interface Prisma__ClassClient<T, Null = never, ExtArgs extends runtime.Ty
|
|||
decks<T extends Prisma.Class$decksArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$decksArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$DeckPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||
quizSets<T extends Prisma.Class$quizSetsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$quizSetsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$QuizSetPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||
materialGroups<T extends Prisma.Class$materialGroupsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$materialGroupsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$MaterialGroupPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||
spacedRepetitionSets<T extends Prisma.Class$spacedRepetitionSetsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$spacedRepetitionSetsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$SpacedRepetitionSetPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||
arcadePacks<T extends Prisma.Class$arcadePacksArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$arcadePacksArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$ArcadePackPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||
/**
|
||||
* Attaches callbacks for the resolution and/or rejection of the Promise.
|
||||
* @param onfulfilled The callback to execute when the Promise is resolved.
|
||||
|
|
@ -1602,6 +1822,54 @@ export type Class$materialGroupsArgs<ExtArgs extends runtime.Types.Extensions.In
|
|||
distinct?: Prisma.MaterialGroupScalarFieldEnum | Prisma.MaterialGroupScalarFieldEnum[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Class.spacedRepetitionSets
|
||||
*/
|
||||
export type Class$spacedRepetitionSetsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
/**
|
||||
* Select specific fields to fetch from the SpacedRepetitionSet
|
||||
*/
|
||||
select?: Prisma.SpacedRepetitionSetSelect<ExtArgs> | null
|
||||
/**
|
||||
* Omit specific fields from the SpacedRepetitionSet
|
||||
*/
|
||||
omit?: Prisma.SpacedRepetitionSetOmit<ExtArgs> | null
|
||||
/**
|
||||
* Choose, which related nodes to fetch as well
|
||||
*/
|
||||
include?: Prisma.SpacedRepetitionSetInclude<ExtArgs> | null
|
||||
where?: Prisma.SpacedRepetitionSetWhereInput
|
||||
orderBy?: Prisma.SpacedRepetitionSetOrderByWithRelationInput | Prisma.SpacedRepetitionSetOrderByWithRelationInput[]
|
||||
cursor?: Prisma.SpacedRepetitionSetWhereUniqueInput
|
||||
take?: number
|
||||
skip?: number
|
||||
distinct?: Prisma.SpacedRepetitionSetScalarFieldEnum | Prisma.SpacedRepetitionSetScalarFieldEnum[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Class.arcadePacks
|
||||
*/
|
||||
export type Class$arcadePacksArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
/**
|
||||
* Select specific fields to fetch from the ArcadePack
|
||||
*/
|
||||
select?: Prisma.ArcadePackSelect<ExtArgs> | null
|
||||
/**
|
||||
* Omit specific fields from the ArcadePack
|
||||
*/
|
||||
omit?: Prisma.ArcadePackOmit<ExtArgs> | null
|
||||
/**
|
||||
* Choose, which related nodes to fetch as well
|
||||
*/
|
||||
include?: Prisma.ArcadePackInclude<ExtArgs> | null
|
||||
where?: Prisma.ArcadePackWhereInput
|
||||
orderBy?: Prisma.ArcadePackOrderByWithRelationInput | Prisma.ArcadePackOrderByWithRelationInput[]
|
||||
cursor?: Prisma.ArcadePackWhereUniqueInput
|
||||
take?: number
|
||||
skip?: number
|
||||
distinct?: Prisma.ArcadePackScalarFieldEnum | Prisma.ArcadePackScalarFieldEnum[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Class without action
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -237,6 +237,7 @@ export type DeckWhereInput = {
|
|||
cards?: Prisma.FlashcardListRelationFilter
|
||||
shareLink?: Prisma.XOR<Prisma.ShareLinkNullableScalarRelationFilter, Prisma.ShareLinkWhereInput> | null
|
||||
progress?: Prisma.StudyProgressListRelationFilter
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckListRelationFilter
|
||||
}
|
||||
|
||||
export type DeckOrderByWithRelationInput = {
|
||||
|
|
@ -252,6 +253,7 @@ export type DeckOrderByWithRelationInput = {
|
|||
cards?: Prisma.FlashcardOrderByRelationAggregateInput
|
||||
shareLink?: Prisma.ShareLinkOrderByWithRelationInput
|
||||
progress?: Prisma.StudyProgressOrderByRelationAggregateInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckOrderByRelationAggregateInput
|
||||
}
|
||||
|
||||
export type DeckWhereUniqueInput = Prisma.AtLeast<{
|
||||
|
|
@ -270,6 +272,7 @@ export type DeckWhereUniqueInput = Prisma.AtLeast<{
|
|||
cards?: Prisma.FlashcardListRelationFilter
|
||||
shareLink?: Prisma.XOR<Prisma.ShareLinkNullableScalarRelationFilter, Prisma.ShareLinkWhereInput> | null
|
||||
progress?: Prisma.StudyProgressListRelationFilter
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckListRelationFilter
|
||||
}, "id">
|
||||
|
||||
export type DeckOrderByWithAggregationInput = {
|
||||
|
|
@ -311,6 +314,7 @@ export type DeckCreateInput = {
|
|||
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
|
||||
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
|
||||
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
|
||||
}
|
||||
|
||||
export type DeckUncheckedCreateInput = {
|
||||
|
|
@ -324,6 +328,7 @@ export type DeckUncheckedCreateInput = {
|
|||
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
|
||||
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
|
||||
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
|
||||
}
|
||||
|
||||
export type DeckUpdateInput = {
|
||||
|
|
@ -337,6 +342,7 @@ export type DeckUpdateInput = {
|
|||
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
|
||||
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
|
||||
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
|
||||
}
|
||||
|
||||
export type DeckUncheckedUpdateInput = {
|
||||
|
|
@ -350,6 +356,7 @@ export type DeckUncheckedUpdateInput = {
|
|||
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
|
||||
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
|
||||
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
|
||||
}
|
||||
|
||||
export type DeckCreateManyInput = {
|
||||
|
|
@ -572,6 +579,20 @@ export type DeckUncheckedUpdateManyWithoutGroupNestedInput = {
|
|||
deleteMany?: Prisma.DeckScalarWhereInput | Prisma.DeckScalarWhereInput[]
|
||||
}
|
||||
|
||||
export type DeckCreateNestedOneWithoutSpacedRepetitionMembershipsInput = {
|
||||
create?: Prisma.XOR<Prisma.DeckCreateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedCreateWithoutSpacedRepetitionMembershipsInput>
|
||||
connectOrCreate?: Prisma.DeckCreateOrConnectWithoutSpacedRepetitionMembershipsInput
|
||||
connect?: Prisma.DeckWhereUniqueInput
|
||||
}
|
||||
|
||||
export type DeckUpdateOneRequiredWithoutSpacedRepetitionMembershipsNestedInput = {
|
||||
create?: Prisma.XOR<Prisma.DeckCreateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedCreateWithoutSpacedRepetitionMembershipsInput>
|
||||
connectOrCreate?: Prisma.DeckCreateOrConnectWithoutSpacedRepetitionMembershipsInput
|
||||
upsert?: Prisma.DeckUpsertWithoutSpacedRepetitionMembershipsInput
|
||||
connect?: Prisma.DeckWhereUniqueInput
|
||||
update?: Prisma.XOR<Prisma.XOR<Prisma.DeckUpdateToOneWithWhereWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUpdateWithoutSpacedRepetitionMembershipsInput>, Prisma.DeckUncheckedUpdateWithoutSpacedRepetitionMembershipsInput>
|
||||
}
|
||||
|
||||
export type DeckCreateWithoutClassInput = {
|
||||
id?: string
|
||||
name: string
|
||||
|
|
@ -582,6 +603,7 @@ export type DeckCreateWithoutClassInput = {
|
|||
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
|
||||
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
|
||||
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
|
||||
}
|
||||
|
||||
export type DeckUncheckedCreateWithoutClassInput = {
|
||||
|
|
@ -594,6 +616,7 @@ export type DeckUncheckedCreateWithoutClassInput = {
|
|||
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
|
||||
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
|
||||
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
|
||||
}
|
||||
|
||||
export type DeckCreateOrConnectWithoutClassInput = {
|
||||
|
|
@ -644,6 +667,7 @@ export type DeckCreateWithoutCardsInput = {
|
|||
group?: Prisma.MaterialGroupCreateNestedOneWithoutDecksInput
|
||||
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
|
||||
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
|
||||
}
|
||||
|
||||
export type DeckUncheckedCreateWithoutCardsInput = {
|
||||
|
|
@ -656,6 +680,7 @@ export type DeckUncheckedCreateWithoutCardsInput = {
|
|||
groupId?: string | null
|
||||
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
|
||||
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
|
||||
}
|
||||
|
||||
export type DeckCreateOrConnectWithoutCardsInput = {
|
||||
|
|
@ -684,6 +709,7 @@ export type DeckUpdateWithoutCardsInput = {
|
|||
group?: Prisma.MaterialGroupUpdateOneWithoutDecksNestedInput
|
||||
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
|
||||
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
|
||||
}
|
||||
|
||||
export type DeckUncheckedUpdateWithoutCardsInput = {
|
||||
|
|
@ -696,6 +722,7 @@ export type DeckUncheckedUpdateWithoutCardsInput = {
|
|||
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
|
||||
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
|
||||
}
|
||||
|
||||
export type DeckCreateWithoutProgressInput = {
|
||||
|
|
@ -708,6 +735,7 @@ export type DeckCreateWithoutProgressInput = {
|
|||
group?: Prisma.MaterialGroupCreateNestedOneWithoutDecksInput
|
||||
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
|
||||
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
|
||||
}
|
||||
|
||||
export type DeckUncheckedCreateWithoutProgressInput = {
|
||||
|
|
@ -720,6 +748,7 @@ export type DeckUncheckedCreateWithoutProgressInput = {
|
|||
groupId?: string | null
|
||||
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
|
||||
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
|
||||
}
|
||||
|
||||
export type DeckCreateOrConnectWithoutProgressInput = {
|
||||
|
|
@ -748,6 +777,7 @@ export type DeckUpdateWithoutProgressInput = {
|
|||
group?: Prisma.MaterialGroupUpdateOneWithoutDecksNestedInput
|
||||
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
|
||||
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
|
||||
}
|
||||
|
||||
export type DeckUncheckedUpdateWithoutProgressInput = {
|
||||
|
|
@ -760,6 +790,7 @@ export type DeckUncheckedUpdateWithoutProgressInput = {
|
|||
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
|
||||
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
|
||||
}
|
||||
|
||||
export type DeckCreateWithoutShareLinkInput = {
|
||||
|
|
@ -772,6 +803,7 @@ export type DeckCreateWithoutShareLinkInput = {
|
|||
group?: Prisma.MaterialGroupCreateNestedOneWithoutDecksInput
|
||||
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
|
||||
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
|
||||
}
|
||||
|
||||
export type DeckUncheckedCreateWithoutShareLinkInput = {
|
||||
|
|
@ -784,6 +816,7 @@ export type DeckUncheckedCreateWithoutShareLinkInput = {
|
|||
groupId?: string | null
|
||||
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
|
||||
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
|
||||
}
|
||||
|
||||
export type DeckCreateOrConnectWithoutShareLinkInput = {
|
||||
|
|
@ -812,6 +845,7 @@ export type DeckUpdateWithoutShareLinkInput = {
|
|||
group?: Prisma.MaterialGroupUpdateOneWithoutDecksNestedInput
|
||||
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
|
||||
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
|
||||
}
|
||||
|
||||
export type DeckUncheckedUpdateWithoutShareLinkInput = {
|
||||
|
|
@ -824,6 +858,7 @@ export type DeckUncheckedUpdateWithoutShareLinkInput = {
|
|||
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
|
||||
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
|
||||
}
|
||||
|
||||
export type DeckCreateWithoutGroupInput = {
|
||||
|
|
@ -836,6 +871,7 @@ export type DeckCreateWithoutGroupInput = {
|
|||
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
|
||||
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
|
||||
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
|
||||
}
|
||||
|
||||
export type DeckUncheckedCreateWithoutGroupInput = {
|
||||
|
|
@ -848,6 +884,7 @@ export type DeckUncheckedCreateWithoutGroupInput = {
|
|||
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
|
||||
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
|
||||
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
|
||||
}
|
||||
|
||||
export type DeckCreateOrConnectWithoutGroupInput = {
|
||||
|
|
@ -875,6 +912,74 @@ export type DeckUpdateManyWithWhereWithoutGroupInput = {
|
|||
data: Prisma.XOR<Prisma.DeckUpdateManyMutationInput, Prisma.DeckUncheckedUpdateManyWithoutGroupInput>
|
||||
}
|
||||
|
||||
export type DeckCreateWithoutSpacedRepetitionMembershipsInput = {
|
||||
id?: string
|
||||
name: string
|
||||
description?: string | null
|
||||
sortOrder?: number
|
||||
createdAt?: Date | string
|
||||
class: Prisma.ClassCreateNestedOneWithoutDecksInput
|
||||
group?: Prisma.MaterialGroupCreateNestedOneWithoutDecksInput
|
||||
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
|
||||
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
|
||||
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
|
||||
}
|
||||
|
||||
export type DeckUncheckedCreateWithoutSpacedRepetitionMembershipsInput = {
|
||||
id?: string
|
||||
classId: string
|
||||
name: string
|
||||
description?: string | null
|
||||
sortOrder?: number
|
||||
createdAt?: Date | string
|
||||
groupId?: string | null
|
||||
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
|
||||
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
|
||||
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
|
||||
}
|
||||
|
||||
export type DeckCreateOrConnectWithoutSpacedRepetitionMembershipsInput = {
|
||||
where: Prisma.DeckWhereUniqueInput
|
||||
create: Prisma.XOR<Prisma.DeckCreateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedCreateWithoutSpacedRepetitionMembershipsInput>
|
||||
}
|
||||
|
||||
export type DeckUpsertWithoutSpacedRepetitionMembershipsInput = {
|
||||
update: Prisma.XOR<Prisma.DeckUpdateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedUpdateWithoutSpacedRepetitionMembershipsInput>
|
||||
create: Prisma.XOR<Prisma.DeckCreateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedCreateWithoutSpacedRepetitionMembershipsInput>
|
||||
where?: Prisma.DeckWhereInput
|
||||
}
|
||||
|
||||
export type DeckUpdateToOneWithWhereWithoutSpacedRepetitionMembershipsInput = {
|
||||
where?: Prisma.DeckWhereInput
|
||||
data: Prisma.XOR<Prisma.DeckUpdateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedUpdateWithoutSpacedRepetitionMembershipsInput>
|
||||
}
|
||||
|
||||
export type DeckUpdateWithoutSpacedRepetitionMembershipsInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
class?: Prisma.ClassUpdateOneRequiredWithoutDecksNestedInput
|
||||
group?: Prisma.MaterialGroupUpdateOneWithoutDecksNestedInput
|
||||
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
|
||||
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
|
||||
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
|
||||
}
|
||||
|
||||
export type DeckUncheckedUpdateWithoutSpacedRepetitionMembershipsInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
classId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
|
||||
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
|
||||
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
|
||||
}
|
||||
|
||||
export type DeckCreateManyClassInput = {
|
||||
id?: string
|
||||
name: string
|
||||
|
|
@ -894,6 +999,7 @@ export type DeckUpdateWithoutClassInput = {
|
|||
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
|
||||
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
|
||||
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
|
||||
}
|
||||
|
||||
export type DeckUncheckedUpdateWithoutClassInput = {
|
||||
|
|
@ -906,6 +1012,7 @@ export type DeckUncheckedUpdateWithoutClassInput = {
|
|||
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
|
||||
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
|
||||
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
|
||||
}
|
||||
|
||||
export type DeckUncheckedUpdateManyWithoutClassInput = {
|
||||
|
|
@ -936,6 +1043,7 @@ export type DeckUpdateWithoutGroupInput = {
|
|||
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
|
||||
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
|
||||
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
|
||||
}
|
||||
|
||||
export type DeckUncheckedUpdateWithoutGroupInput = {
|
||||
|
|
@ -948,6 +1056,7 @@ export type DeckUncheckedUpdateWithoutGroupInput = {
|
|||
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
|
||||
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
|
||||
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
|
||||
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
|
||||
}
|
||||
|
||||
export type DeckUncheckedUpdateManyWithoutGroupInput = {
|
||||
|
|
@ -967,11 +1076,13 @@ export type DeckUncheckedUpdateManyWithoutGroupInput = {
|
|||
export type DeckCountOutputType = {
|
||||
cards: number
|
||||
progress: number
|
||||
spacedRepetitionMemberships: number
|
||||
}
|
||||
|
||||
export type DeckCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
cards?: boolean | DeckCountOutputTypeCountCardsArgs
|
||||
progress?: boolean | DeckCountOutputTypeCountProgressArgs
|
||||
spacedRepetitionMemberships?: boolean | DeckCountOutputTypeCountSpacedRepetitionMembershipsArgs
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -998,6 +1109,13 @@ export type DeckCountOutputTypeCountProgressArgs<ExtArgs extends runtime.Types.E
|
|||
where?: Prisma.StudyProgressWhereInput
|
||||
}
|
||||
|
||||
/**
|
||||
* DeckCountOutputType without action
|
||||
*/
|
||||
export type DeckCountOutputTypeCountSpacedRepetitionMembershipsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
where?: Prisma.SpacedRepetitionSetDeckWhereInput
|
||||
}
|
||||
|
||||
|
||||
export type DeckSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||
id?: boolean
|
||||
|
|
@ -1012,6 +1130,7 @@ export type DeckSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
|
|||
cards?: boolean | Prisma.Deck$cardsArgs<ExtArgs>
|
||||
shareLink?: boolean | Prisma.Deck$shareLinkArgs<ExtArgs>
|
||||
progress?: boolean | Prisma.Deck$progressArgs<ExtArgs>
|
||||
spacedRepetitionMemberships?: boolean | Prisma.Deck$spacedRepetitionMembershipsArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.DeckCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}, ExtArgs["result"]["deck"]>
|
||||
|
||||
|
|
@ -1056,6 +1175,7 @@ export type DeckInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
|||
cards?: boolean | Prisma.Deck$cardsArgs<ExtArgs>
|
||||
shareLink?: boolean | Prisma.Deck$shareLinkArgs<ExtArgs>
|
||||
progress?: boolean | Prisma.Deck$progressArgs<ExtArgs>
|
||||
spacedRepetitionMemberships?: boolean | Prisma.Deck$spacedRepetitionMembershipsArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.DeckCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}
|
||||
export type DeckIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
|
|
@ -1075,6 +1195,7 @@ export type $DeckPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
|||
cards: Prisma.$FlashcardPayload<ExtArgs>[]
|
||||
shareLink: Prisma.$ShareLinkPayload<ExtArgs> | null
|
||||
progress: Prisma.$StudyProgressPayload<ExtArgs>[]
|
||||
spacedRepetitionMemberships: Prisma.$SpacedRepetitionSetDeckPayload<ExtArgs>[]
|
||||
}
|
||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||
id: string
|
||||
|
|
@ -1483,6 +1604,7 @@ export interface Prisma__DeckClient<T, Null = never, ExtArgs extends runtime.Typ
|
|||
cards<T extends Prisma.Deck$cardsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Deck$cardsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$FlashcardPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||
shareLink<T extends Prisma.Deck$shareLinkArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Deck$shareLinkArgs<ExtArgs>>): Prisma.Prisma__ShareLinkClient<runtime.Types.Result.GetResult<Prisma.$ShareLinkPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
|
||||
progress<T extends Prisma.Deck$progressArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Deck$progressArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$StudyProgressPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||
spacedRepetitionMemberships<T extends Prisma.Deck$spacedRepetitionMembershipsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Deck$spacedRepetitionMembershipsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$SpacedRepetitionSetDeckPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||
/**
|
||||
* Attaches callbacks for the resolution and/or rejection of the Promise.
|
||||
* @param onfulfilled The callback to execute when the Promise is resolved.
|
||||
|
|
@ -2003,6 +2125,30 @@ export type Deck$progressArgs<ExtArgs extends runtime.Types.Extensions.InternalA
|
|||
distinct?: Prisma.StudyProgressScalarFieldEnum | Prisma.StudyProgressScalarFieldEnum[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Deck.spacedRepetitionMemberships
|
||||
*/
|
||||
export type Deck$spacedRepetitionMembershipsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
/**
|
||||
* Select specific fields to fetch from the SpacedRepetitionSetDeck
|
||||
*/
|
||||
select?: Prisma.SpacedRepetitionSetDeckSelect<ExtArgs> | null
|
||||
/**
|
||||
* Omit specific fields from the SpacedRepetitionSetDeck
|
||||
*/
|
||||
omit?: Prisma.SpacedRepetitionSetDeckOmit<ExtArgs> | null
|
||||
/**
|
||||
* Choose, which related nodes to fetch as well
|
||||
*/
|
||||
include?: Prisma.SpacedRepetitionSetDeckInclude<ExtArgs> | null
|
||||
where?: Prisma.SpacedRepetitionSetDeckWhereInput
|
||||
orderBy?: Prisma.SpacedRepetitionSetDeckOrderByWithRelationInput | Prisma.SpacedRepetitionSetDeckOrderByWithRelationInput[]
|
||||
cursor?: Prisma.SpacedRepetitionSetDeckWhereUniqueInput
|
||||
take?: number
|
||||
skip?: number
|
||||
distinct?: Prisma.SpacedRepetitionSetDeckScalarFieldEnum | Prisma.SpacedRepetitionSetDeckScalarFieldEnum[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Deck without action
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -217,6 +217,7 @@ export type FlashcardWhereInput = {
|
|||
back?: Prisma.StringFilter<"Flashcard"> | string
|
||||
sortOrder?: Prisma.IntFilter<"Flashcard"> | number
|
||||
deck?: Prisma.XOR<Prisma.DeckScalarRelationFilter, Prisma.DeckWhereInput>
|
||||
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateListRelationFilter
|
||||
}
|
||||
|
||||
export type FlashcardOrderByWithRelationInput = {
|
||||
|
|
@ -226,6 +227,7 @@ export type FlashcardOrderByWithRelationInput = {
|
|||
back?: Prisma.SortOrder
|
||||
sortOrder?: Prisma.SortOrder
|
||||
deck?: Prisma.DeckOrderByWithRelationInput
|
||||
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateOrderByRelationAggregateInput
|
||||
}
|
||||
|
||||
export type FlashcardWhereUniqueInput = Prisma.AtLeast<{
|
||||
|
|
@ -238,6 +240,7 @@ export type FlashcardWhereUniqueInput = Prisma.AtLeast<{
|
|||
back?: Prisma.StringFilter<"Flashcard"> | string
|
||||
sortOrder?: Prisma.IntFilter<"Flashcard"> | number
|
||||
deck?: Prisma.XOR<Prisma.DeckScalarRelationFilter, Prisma.DeckWhereInput>
|
||||
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateListRelationFilter
|
||||
}, "id">
|
||||
|
||||
export type FlashcardOrderByWithAggregationInput = {
|
||||
|
|
@ -270,6 +273,7 @@ export type FlashcardCreateInput = {
|
|||
back: string
|
||||
sortOrder?: number
|
||||
deck: Prisma.DeckCreateNestedOneWithoutCardsInput
|
||||
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateCreateNestedManyWithoutFlashcardInput
|
||||
}
|
||||
|
||||
export type FlashcardUncheckedCreateInput = {
|
||||
|
|
@ -278,6 +282,7 @@ export type FlashcardUncheckedCreateInput = {
|
|||
front: string
|
||||
back: string
|
||||
sortOrder?: number
|
||||
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUncheckedCreateNestedManyWithoutFlashcardInput
|
||||
}
|
||||
|
||||
export type FlashcardUpdateInput = {
|
||||
|
|
@ -286,6 +291,7 @@ export type FlashcardUpdateInput = {
|
|||
back?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
deck?: Prisma.DeckUpdateOneRequiredWithoutCardsNestedInput
|
||||
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUpdateManyWithoutFlashcardNestedInput
|
||||
}
|
||||
|
||||
export type FlashcardUncheckedUpdateInput = {
|
||||
|
|
@ -294,6 +300,7 @@ export type FlashcardUncheckedUpdateInput = {
|
|||
front?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
back?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUncheckedUpdateManyWithoutFlashcardNestedInput
|
||||
}
|
||||
|
||||
export type FlashcardCreateManyInput = {
|
||||
|
|
@ -361,6 +368,11 @@ export type FlashcardSumOrderByAggregateInput = {
|
|||
sortOrder?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type FlashcardScalarRelationFilter = {
|
||||
is?: Prisma.FlashcardWhereInput
|
||||
isNot?: Prisma.FlashcardWhereInput
|
||||
}
|
||||
|
||||
export type FlashcardCreateNestedManyWithoutDeckInput = {
|
||||
create?: Prisma.XOR<Prisma.FlashcardCreateWithoutDeckInput, Prisma.FlashcardUncheckedCreateWithoutDeckInput> | Prisma.FlashcardCreateWithoutDeckInput[] | Prisma.FlashcardUncheckedCreateWithoutDeckInput[]
|
||||
connectOrCreate?: Prisma.FlashcardCreateOrConnectWithoutDeckInput | Prisma.FlashcardCreateOrConnectWithoutDeckInput[]
|
||||
|
|
@ -403,11 +415,26 @@ export type FlashcardUncheckedUpdateManyWithoutDeckNestedInput = {
|
|||
deleteMany?: Prisma.FlashcardScalarWhereInput | Prisma.FlashcardScalarWhereInput[]
|
||||
}
|
||||
|
||||
export type FlashcardCreateNestedOneWithoutSpacedRepetitionStatesInput = {
|
||||
create?: Prisma.XOR<Prisma.FlashcardCreateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedCreateWithoutSpacedRepetitionStatesInput>
|
||||
connectOrCreate?: Prisma.FlashcardCreateOrConnectWithoutSpacedRepetitionStatesInput
|
||||
connect?: Prisma.FlashcardWhereUniqueInput
|
||||
}
|
||||
|
||||
export type FlashcardUpdateOneRequiredWithoutSpacedRepetitionStatesNestedInput = {
|
||||
create?: Prisma.XOR<Prisma.FlashcardCreateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedCreateWithoutSpacedRepetitionStatesInput>
|
||||
connectOrCreate?: Prisma.FlashcardCreateOrConnectWithoutSpacedRepetitionStatesInput
|
||||
upsert?: Prisma.FlashcardUpsertWithoutSpacedRepetitionStatesInput
|
||||
connect?: Prisma.FlashcardWhereUniqueInput
|
||||
update?: Prisma.XOR<Prisma.XOR<Prisma.FlashcardUpdateToOneWithWhereWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUpdateWithoutSpacedRepetitionStatesInput>, Prisma.FlashcardUncheckedUpdateWithoutSpacedRepetitionStatesInput>
|
||||
}
|
||||
|
||||
export type FlashcardCreateWithoutDeckInput = {
|
||||
id?: string
|
||||
front: string
|
||||
back: string
|
||||
sortOrder?: number
|
||||
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateCreateNestedManyWithoutFlashcardInput
|
||||
}
|
||||
|
||||
export type FlashcardUncheckedCreateWithoutDeckInput = {
|
||||
|
|
@ -415,6 +442,7 @@ export type FlashcardUncheckedCreateWithoutDeckInput = {
|
|||
front: string
|
||||
back: string
|
||||
sortOrder?: number
|
||||
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUncheckedCreateNestedManyWithoutFlashcardInput
|
||||
}
|
||||
|
||||
export type FlashcardCreateOrConnectWithoutDeckInput = {
|
||||
|
|
@ -453,6 +481,54 @@ export type FlashcardScalarWhereInput = {
|
|||
sortOrder?: Prisma.IntFilter<"Flashcard"> | number
|
||||
}
|
||||
|
||||
export type FlashcardCreateWithoutSpacedRepetitionStatesInput = {
|
||||
id?: string
|
||||
front: string
|
||||
back: string
|
||||
sortOrder?: number
|
||||
deck: Prisma.DeckCreateNestedOneWithoutCardsInput
|
||||
}
|
||||
|
||||
export type FlashcardUncheckedCreateWithoutSpacedRepetitionStatesInput = {
|
||||
id?: string
|
||||
deckId: string
|
||||
front: string
|
||||
back: string
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export type FlashcardCreateOrConnectWithoutSpacedRepetitionStatesInput = {
|
||||
where: Prisma.FlashcardWhereUniqueInput
|
||||
create: Prisma.XOR<Prisma.FlashcardCreateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedCreateWithoutSpacedRepetitionStatesInput>
|
||||
}
|
||||
|
||||
export type FlashcardUpsertWithoutSpacedRepetitionStatesInput = {
|
||||
update: Prisma.XOR<Prisma.FlashcardUpdateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedUpdateWithoutSpacedRepetitionStatesInput>
|
||||
create: Prisma.XOR<Prisma.FlashcardCreateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedCreateWithoutSpacedRepetitionStatesInput>
|
||||
where?: Prisma.FlashcardWhereInput
|
||||
}
|
||||
|
||||
export type FlashcardUpdateToOneWithWhereWithoutSpacedRepetitionStatesInput = {
|
||||
where?: Prisma.FlashcardWhereInput
|
||||
data: Prisma.XOR<Prisma.FlashcardUpdateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedUpdateWithoutSpacedRepetitionStatesInput>
|
||||
}
|
||||
|
||||
export type FlashcardUpdateWithoutSpacedRepetitionStatesInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
front?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
back?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
deck?: Prisma.DeckUpdateOneRequiredWithoutCardsNestedInput
|
||||
}
|
||||
|
||||
export type FlashcardUncheckedUpdateWithoutSpacedRepetitionStatesInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
deckId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
front?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
back?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
}
|
||||
|
||||
export type FlashcardCreateManyDeckInput = {
|
||||
id?: string
|
||||
front: string
|
||||
|
|
@ -465,6 +541,7 @@ export type FlashcardUpdateWithoutDeckInput = {
|
|||
front?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
back?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUpdateManyWithoutFlashcardNestedInput
|
||||
}
|
||||
|
||||
export type FlashcardUncheckedUpdateWithoutDeckInput = {
|
||||
|
|
@ -472,6 +549,7 @@ export type FlashcardUncheckedUpdateWithoutDeckInput = {
|
|||
front?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
back?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUncheckedUpdateManyWithoutFlashcardNestedInput
|
||||
}
|
||||
|
||||
export type FlashcardUncheckedUpdateManyWithoutDeckInput = {
|
||||
|
|
@ -482,6 +560,35 @@ export type FlashcardUncheckedUpdateManyWithoutDeckInput = {
|
|||
}
|
||||
|
||||
|
||||
/**
|
||||
* Count Type FlashcardCountOutputType
|
||||
*/
|
||||
|
||||
export type FlashcardCountOutputType = {
|
||||
spacedRepetitionStates: number
|
||||
}
|
||||
|
||||
export type FlashcardCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
spacedRepetitionStates?: boolean | FlashcardCountOutputTypeCountSpacedRepetitionStatesArgs
|
||||
}
|
||||
|
||||
/**
|
||||
* FlashcardCountOutputType without action
|
||||
*/
|
||||
export type FlashcardCountOutputTypeDefaultArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
/**
|
||||
* Select specific fields to fetch from the FlashcardCountOutputType
|
||||
*/
|
||||
select?: Prisma.FlashcardCountOutputTypeSelect<ExtArgs> | null
|
||||
}
|
||||
|
||||
/**
|
||||
* FlashcardCountOutputType without action
|
||||
*/
|
||||
export type FlashcardCountOutputTypeCountSpacedRepetitionStatesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
where?: Prisma.SpacedRepetitionCardStateWhereInput
|
||||
}
|
||||
|
||||
|
||||
export type FlashcardSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||
id?: boolean
|
||||
|
|
@ -490,6 +597,8 @@ export type FlashcardSelect<ExtArgs extends runtime.Types.Extensions.InternalArg
|
|||
back?: boolean
|
||||
sortOrder?: boolean
|
||||
deck?: boolean | Prisma.DeckDefaultArgs<ExtArgs>
|
||||
spacedRepetitionStates?: boolean | Prisma.Flashcard$spacedRepetitionStatesArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.FlashcardCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}, ExtArgs["result"]["flashcard"]>
|
||||
|
||||
export type FlashcardSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||
|
|
@ -521,6 +630,8 @@ export type FlashcardSelectScalar = {
|
|||
export type FlashcardOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "deckId" | "front" | "back" | "sortOrder", ExtArgs["result"]["flashcard"]>
|
||||
export type FlashcardInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
deck?: boolean | Prisma.DeckDefaultArgs<ExtArgs>
|
||||
spacedRepetitionStates?: boolean | Prisma.Flashcard$spacedRepetitionStatesArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.FlashcardCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}
|
||||
export type FlashcardIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
deck?: boolean | Prisma.DeckDefaultArgs<ExtArgs>
|
||||
|
|
@ -533,6 +644,7 @@ export type $FlashcardPayload<ExtArgs extends runtime.Types.Extensions.InternalA
|
|||
name: "Flashcard"
|
||||
objects: {
|
||||
deck: Prisma.$DeckPayload<ExtArgs>
|
||||
spacedRepetitionStates: Prisma.$SpacedRepetitionCardStatePayload<ExtArgs>[]
|
||||
}
|
||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||
id: string
|
||||
|
|
@ -935,6 +1047,7 @@ readonly fields: FlashcardFieldRefs;
|
|||
export interface Prisma__FlashcardClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
|
||||
readonly [Symbol.toStringTag]: "PrismaPromise"
|
||||
deck<T extends Prisma.DeckDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.DeckDefaultArgs<ExtArgs>>): Prisma.Prisma__DeckClient<runtime.Types.Result.GetResult<Prisma.$DeckPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
|
||||
spacedRepetitionStates<T extends Prisma.Flashcard$spacedRepetitionStatesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Flashcard$spacedRepetitionStatesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$SpacedRepetitionCardStatePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||
/**
|
||||
* Attaches callbacks for the resolution and/or rejection of the Promise.
|
||||
* @param onfulfilled The callback to execute when the Promise is resolved.
|
||||
|
|
@ -1367,6 +1480,30 @@ export type FlashcardDeleteManyArgs<ExtArgs extends runtime.Types.Extensions.Int
|
|||
limit?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Flashcard.spacedRepetitionStates
|
||||
*/
|
||||
export type Flashcard$spacedRepetitionStatesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
/**
|
||||
* Select specific fields to fetch from the SpacedRepetitionCardState
|
||||
*/
|
||||
select?: Prisma.SpacedRepetitionCardStateSelect<ExtArgs> | null
|
||||
/**
|
||||
* Omit specific fields from the SpacedRepetitionCardState
|
||||
*/
|
||||
omit?: Prisma.SpacedRepetitionCardStateOmit<ExtArgs> | null
|
||||
/**
|
||||
* Choose, which related nodes to fetch as well
|
||||
*/
|
||||
include?: Prisma.SpacedRepetitionCardStateInclude<ExtArgs> | null
|
||||
where?: Prisma.SpacedRepetitionCardStateWhereInput
|
||||
orderBy?: Prisma.SpacedRepetitionCardStateOrderByWithRelationInput | Prisma.SpacedRepetitionCardStateOrderByWithRelationInput[]
|
||||
cursor?: Prisma.SpacedRepetitionCardStateWhereUniqueInput
|
||||
take?: number
|
||||
skip?: number
|
||||
distinct?: Prisma.SpacedRepetitionCardStateScalarFieldEnum | Prisma.SpacedRepetitionCardStateScalarFieldEnum[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Flashcard without action
|
||||
*/
|
||||
|
|
|
|||
2004
src/generated/prisma/models/SpacedRepetitionCardState.ts
Normal file
2004
src/generated/prisma/models/SpacedRepetitionCardState.ts
Normal file
File diff suppressed because it is too large
Load diff
1776
src/generated/prisma/models/SpacedRepetitionSet.ts
Normal file
1776
src/generated/prisma/models/SpacedRepetitionSet.ts
Normal file
File diff suppressed because it is too large
Load diff
1470
src/generated/prisma/models/SpacedRepetitionSetDeck.ts
Normal file
1470
src/generated/prisma/models/SpacedRepetitionSetDeck.ts
Normal file
File diff suppressed because it is too large
Load diff
1091
src/generated/prisma/models/StudyActivity.ts
Normal file
1091
src/generated/prisma/models/StudyActivity.ts
Normal file
File diff suppressed because it is too large
Load diff
9
src/lib/activityClient.ts
Normal file
9
src/lib/activityClient.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import type { StudyActivityType } from "@/lib/validation/activitySchemas";
|
||||
|
||||
export function recordStudyActivity(type: StudyActivityType) {
|
||||
return fetch("/api/activity", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type }),
|
||||
});
|
||||
}
|
||||
5
src/lib/arcade/arcadeImport.ts
Normal file
5
src/lib/arcade/arcadeImport.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export class ArcadeImportError extends Error {
|
||||
constructor(message: string, public readonly details: string[] = []) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
63
src/lib/arcade/connectionsEngine.test.ts
Normal file
63
src/lib/arcade/connectionsEngine.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { deterministicShuffle, replayConnectionsAttempt } from "@/lib/arcade/connectionsEngine";
|
||||
import type { NormalizedConnectionsPack } from "@/types/arcade";
|
||||
|
||||
const pack: NormalizedConnectionsPack = {
|
||||
schemaVersion: 1,
|
||||
type: "connections",
|
||||
name: "Test",
|
||||
settings: { groupCount: 4, itemsPerGroup: 4, allowedMistakes: 4 },
|
||||
content: Array.from({ length: 4 }, (_, groupIndex) => ({
|
||||
id: `group-${groupIndex + 1}`,
|
||||
category: `Category ${groupIndex + 1}`,
|
||||
explanation: `Explanation ${groupIndex + 1}`,
|
||||
items: Array.from({ length: 4 }, (_, itemIndex) => ({
|
||||
id: `g${groupIndex + 1}-i${itemIndex + 1}`,
|
||||
text: `Item ${groupIndex + 1}-${itemIndex + 1}`,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
const settings = { allowedMistakes: 4, oneAwayFeedback: true };
|
||||
|
||||
describe("Connections engine", () => {
|
||||
it("shuffles deterministically for a seed", () => {
|
||||
const items = Array.from({ length: 16 }, (_, index) => index);
|
||||
expect(deterministicShuffle(items, "round-1")).toEqual(deterministicShuffle(items, "round-1"));
|
||||
expect(deterministicShuffle(items, "round-1")).not.toEqual(deterministicShuffle(items, "round-2"));
|
||||
});
|
||||
|
||||
it("scores a perfect completed round", () => {
|
||||
const submissions = pack.content.map((group, index) => ({
|
||||
itemIds: group.items.map((item) => item.id),
|
||||
elapsedMs: (index + 1) * 1000,
|
||||
}));
|
||||
const replay = replayConnectionsAttempt(pack, submissions, settings, 12);
|
||||
expect(replay.complete).toBe(true);
|
||||
expect(replay.result).toMatchObject({ outcome: "WON", score: 400, mistakes: 0, accuracy: 1 });
|
||||
});
|
||||
|
||||
it("deducts mistakes and detects one-away selections", () => {
|
||||
const wrong = [
|
||||
...pack.content[0].items.slice(0, 3).map((item) => item.id),
|
||||
pack.content[1].items[0].id,
|
||||
];
|
||||
const submissions = [
|
||||
{ itemIds: wrong, elapsedMs: 1000 },
|
||||
...pack.content.map((group, index) => ({ itemIds: group.items.map((item) => item.id), elapsedMs: (index + 2) * 1000 })),
|
||||
];
|
||||
const replay = replayConnectionsAttempt(pack, submissions, settings, 20);
|
||||
expect(replay.result).toMatchObject({ outcome: "WON", score: 390, mistakes: 1, hintsUsed: 1, accuracy: 0.8 });
|
||||
});
|
||||
|
||||
it("ends after the configured number of mistakes", () => {
|
||||
const wrong = [pack.content[0].items[0].id, pack.content[1].items[0].id, pack.content[2].items[0].id, pack.content[3].items[0].id];
|
||||
const replay = replayConnectionsAttempt(pack, Array.from({ length: 4 }, (_, index) => ({ itemIds: wrong, elapsedMs: index * 1000 })), settings, 10);
|
||||
expect(replay.complete).toBe(true);
|
||||
expect(replay.result).toMatchObject({ outcome: "LOST", score: 0, mistakes: 4 });
|
||||
});
|
||||
|
||||
it("rejects selections containing unknown item ids", () => {
|
||||
expect(() => replayConnectionsAttempt(pack, [{ itemIds: ["missing", "a", "b", "c"], elapsedMs: 0 }], settings, 0)).toThrow("invalid tile selection");
|
||||
});
|
||||
});
|
||||
110
src/lib/arcade/connectionsEngine.ts
Normal file
110
src/lib/arcade/connectionsEngine.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import type {
|
||||
ArcadeRoundResult,
|
||||
ArcadeSessionSettings,
|
||||
ConnectionsSubmission,
|
||||
NormalizedConnectionsGroup,
|
||||
NormalizedConnectionsPack,
|
||||
} from "@/types/arcade";
|
||||
|
||||
function hashSeed(seed: string) {
|
||||
let value = 2166136261;
|
||||
for (let index = 0; index < seed.length; index += 1) {
|
||||
value ^= seed.charCodeAt(index);
|
||||
value = Math.imul(value, 16777619);
|
||||
}
|
||||
return value >>> 0;
|
||||
}
|
||||
|
||||
function seededRandom(seed: string) {
|
||||
let state = hashSeed(seed);
|
||||
return () => {
|
||||
state += 0x6d2b79f5;
|
||||
let value = state;
|
||||
value = Math.imul(value ^ (value >>> 15), value | 1);
|
||||
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
||||
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export function deterministicShuffle<T>(items: readonly T[], seed: string): T[] {
|
||||
const result = [...items];
|
||||
const random = seededRandom(seed);
|
||||
for (let index = result.length - 1; index > 0; index -= 1) {
|
||||
const swapIndex = Math.floor(random() * (index + 1));
|
||||
[result[index], result[swapIndex]] = [result[swapIndex], result[index]];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function groupForSelection(groups: NormalizedConnectionsGroup[], itemIds: string[]) {
|
||||
const selected = new Set(itemIds);
|
||||
return groups.find(
|
||||
(group) => group.items.length === selected.size && group.items.every((item) => selected.has(item.id))
|
||||
);
|
||||
}
|
||||
|
||||
export function isOneAway(
|
||||
groups: NormalizedConnectionsGroup[],
|
||||
selectedItemIds: string[],
|
||||
solvedGroupIds: Set<string>
|
||||
) {
|
||||
const selected = new Set(selectedItemIds);
|
||||
return groups.some(
|
||||
(group) =>
|
||||
!solvedGroupIds.has(group.id) &&
|
||||
group.items.filter((item) => selected.has(item.id)).length === 3
|
||||
);
|
||||
}
|
||||
|
||||
export function replayConnectionsAttempt(
|
||||
pack: NormalizedConnectionsPack,
|
||||
submissions: ConnectionsSubmission[],
|
||||
settings: ArcadeSessionSettings,
|
||||
durationSeconds: number
|
||||
): { complete: boolean; result: ArcadeRoundResult } {
|
||||
const validItemIds = new Set(pack.content.flatMap((group) => group.items.map((item) => item.id)));
|
||||
const solvedGroupIds = new Set<string>();
|
||||
const incorrectSelections: string[][] = [];
|
||||
let hintsUsed = 0;
|
||||
let mistakes = 0;
|
||||
let processed = 0;
|
||||
|
||||
for (const submission of submissions) {
|
||||
if (solvedGroupIds.size === pack.content.length || mistakes >= settings.allowedMistakes) break;
|
||||
if (new Set(submission.itemIds).size !== 4 || submission.itemIds.some((id) => !validItemIds.has(id))) {
|
||||
throw new Error("An attempt contains an invalid tile selection.");
|
||||
}
|
||||
processed += 1;
|
||||
const group = groupForSelection(pack.content, submission.itemIds);
|
||||
if (group && !solvedGroupIds.has(group.id)) {
|
||||
solvedGroupIds.add(group.id);
|
||||
} else {
|
||||
if (settings.oneAwayFeedback && isOneAway(pack.content, submission.itemIds, solvedGroupIds)) {
|
||||
hintsUsed += 1;
|
||||
}
|
||||
mistakes += 1;
|
||||
incorrectSelections.push([...submission.itemIds]);
|
||||
}
|
||||
}
|
||||
|
||||
const complete = solvedGroupIds.size === pack.content.length || mistakes >= settings.allowedMistakes;
|
||||
const score = Math.max(0, solvedGroupIds.size * 100 - mistakes * 10);
|
||||
const result: ArcadeRoundResult = {
|
||||
outcome: solvedGroupIds.size === pack.content.length ? "WON" : "LOST",
|
||||
score,
|
||||
maxScore: 400,
|
||||
accuracy: processed === 0 ? 0 : solvedGroupIds.size / processed,
|
||||
durationSeconds,
|
||||
mistakes,
|
||||
hintsUsed,
|
||||
groups: pack.content.map((group) => ({
|
||||
groupId: group.id,
|
||||
category: group.category,
|
||||
items: group.items.map((item) => item.text),
|
||||
explanation: group.explanation,
|
||||
solved: solvedGroupIds.has(group.id),
|
||||
})),
|
||||
incorrectSelections,
|
||||
};
|
||||
return { complete, result };
|
||||
}
|
||||
74
src/lib/arcade/connectionsImport.test.ts
Normal file
74
src/lib/arcade/connectionsImport.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { ArcadeImportError, parseConnectionsImport, parseConnectionsImportBatch } from "@/lib/arcade/connectionsImport";
|
||||
|
||||
function validPack() {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
type: "connections",
|
||||
name: "Medication Connections",
|
||||
description: "Four medication groups",
|
||||
settings: { groupCount: 4, itemsPerGroup: 4, allowedMistakes: 4 },
|
||||
content: Array.from({ length: 4 }, (_, groupIndex) => ({
|
||||
category: `Category ${groupIndex + 1}`,
|
||||
items: Array.from({ length: 4 }, (_, itemIndex) => `Item ${groupIndex + 1}-${itemIndex + 1}`),
|
||||
explanation: `Explanation ${groupIndex + 1}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Connections imports", () => {
|
||||
it("normalizes a valid board and creates stable ids", () => {
|
||||
const result = parseConnectionsImport(JSON.stringify(validPack()));
|
||||
expect(result.preview.itemCount).toBe(16);
|
||||
expect(result.preview.itemCount).toBe(16);
|
||||
expect(result.preview.normalized.content[0].id).toBe("group-1");
|
||||
expect(result.preview.normalized.content[0].items[0].id).toBe("group-1-item-1");
|
||||
});
|
||||
|
||||
it("rejects fences and surrounding prose", () => {
|
||||
expect(() => parseConnectionsImport(`\`\`\`json\n${JSON.stringify(validPack())}\n\`\`\``)).toThrow(ArcadeImportError);
|
||||
expect(() => parseConnectionsImport(`Here is the pack: ${JSON.stringify(validPack())}`)).toThrow(ArcadeImportError);
|
||||
});
|
||||
|
||||
it("rejects duplicate tiles case-insensitively", () => {
|
||||
const pack = validPack();
|
||||
pack.content[1].items[0] = " item 1-1 ";
|
||||
expect(() => parseConnectionsImport(JSON.stringify(pack))).toThrow("Every tile must be unique");
|
||||
});
|
||||
|
||||
it("rejects unknown keys", () => {
|
||||
const pack = { ...validPack(), extra: true };
|
||||
expect(() => parseConnectionsImport(JSON.stringify(pack))).toThrow("does not match schema version 1");
|
||||
});
|
||||
|
||||
it("reports long-tile warnings without changing the content", () => {
|
||||
const pack = validPack();
|
||||
pack.content[0].items[0] = "This intentionally long tile contains too many words";
|
||||
const result = parseConnectionsImport(JSON.stringify(pack));
|
||||
expect(result.report.warnings.length).toBeGreaterThan(0);
|
||||
expect(result.preview.normalized.content[0].items[0].text).toBe(pack.content[0].items[0]);
|
||||
});
|
||||
|
||||
it("does not treat a shared generic word as a similar category", () => {
|
||||
const pack = validPack();
|
||||
pack.content[0].category = "ACE Inhibitors";
|
||||
pack.content[1].category = "Proton Pump Inhibitors";
|
||||
const result = parseConnectionsImport(JSON.stringify(pack));
|
||||
expect(result.report.warnings).not.toContain(
|
||||
"Categories “ACE Inhibitors” and “Proton Pump Inhibitors” may be too similar."
|
||||
);
|
||||
});
|
||||
|
||||
it("previews multiple packs from one JSON array", () => {
|
||||
const first = validPack();
|
||||
const second = { ...validPack(), name: "Second board" };
|
||||
const result = parseConnectionsImportBatch(JSON.stringify([first, second]));
|
||||
expect(result.count).toBe(2);
|
||||
expect(result.packs.map((pack) => pack.name)).toEqual(["Medication Connections", "Second board"]);
|
||||
});
|
||||
|
||||
it("limits batch imports to ten packs", () => {
|
||||
const batch = Array.from({ length: 11 }, () => validPack());
|
||||
expect(() => parseConnectionsImportBatch(JSON.stringify(batch))).toThrow("between 1 and 10");
|
||||
});
|
||||
});
|
||||
168
src/lib/arcade/connectionsImport.ts
Normal file
168
src/lib/arcade/connectionsImport.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { parseAndRepairJson } from "@/lib/jsonRepair";
|
||||
import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
|
||||
import { connectionsImportSchema } from "@/lib/validation/arcadeSchemas";
|
||||
import type {
|
||||
ArcadeImportPreview,
|
||||
ArcadeImportBatchPreview,
|
||||
ArcadeValidationReport,
|
||||
NormalizedConnectionsPack,
|
||||
} from "@/types/arcade";
|
||||
|
||||
export { ArcadeImportError } from "@/lib/arcade/arcadeImport";
|
||||
|
||||
function clean(value: string) {
|
||||
return value.trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function comparisonKey(value: string) {
|
||||
return clean(value).toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function safeId(value: string, fallback: string) {
|
||||
const id = value
|
||||
.trim()
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
return id || fallback;
|
||||
}
|
||||
|
||||
function findWarnings(pack: NormalizedConnectionsPack) {
|
||||
const warnings: string[] = [];
|
||||
const categoryKeys = pack.content.map((group) => comparisonKey(group.category));
|
||||
|
||||
for (const group of pack.content) {
|
||||
for (const item of group.items) {
|
||||
if (item.text.length > 40) {
|
||||
warnings.push(`“${item.text}” is longer than 40 characters and may be difficult to scan.`);
|
||||
} else if (item.text.split(/\s+/).length > 4) {
|
||||
warnings.push(`“${item.text}” contains more than four words.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let left = 0; left < categoryKeys.length; left += 1) {
|
||||
for (let right = left + 1; right < categoryKeys.length; right += 1) {
|
||||
const a = categoryKeys[left];
|
||||
const b = categoryKeys[right];
|
||||
if (a.includes(b) || b.includes(a)) {
|
||||
warnings.push(
|
||||
`Categories “${pack.content[left].category}” and “${pack.content[right].category}” may be too similar.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(warnings)];
|
||||
}
|
||||
|
||||
export function parseConnectionsImportBatch(rawJson: string): ArcadeImportBatchPreview<NormalizedConnectionsPack> {
|
||||
const trimmed = rawJson.trim();
|
||||
const isObject = trimmed.startsWith("{") && trimmed.endsWith("}");
|
||||
const isArray = trimmed.startsWith("[") && trimmed.endsWith("]");
|
||||
if (trimmed.includes("```") || (!isObject && !isArray)) {
|
||||
throw new ArcadeImportError("Paste one raw JSON object or an array of pack objects without Markdown fences or surrounding prose.");
|
||||
}
|
||||
|
||||
const repaired = parseAndRepairJson(trimmed);
|
||||
if (!repaired.success) {
|
||||
throw new ArcadeImportError(repaired.error ?? "The JSON could not be parsed.");
|
||||
}
|
||||
const values = Array.isArray(repaired.data) ? repaired.data : [repaired.data];
|
||||
if (values.length < 1 || values.length > 10) {
|
||||
throw new ArcadeImportError("Import between 1 and 10 Connections packs at a time.");
|
||||
}
|
||||
|
||||
const packs = values.map((value, index) => {
|
||||
try {
|
||||
const parsed = parseConnectionsImport(JSON.stringify(value));
|
||||
return {
|
||||
...parsed.preview,
|
||||
wasRepaired: parsed.preview.wasRepaired || repaired.wasRepaired,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ArcadeImportError) {
|
||||
throw new ArcadeImportError(`Pack ${index + 1}: ${error.message}`, error.details);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
return { packs, count: packs.length, wasRepaired: repaired.wasRepaired };
|
||||
}
|
||||
|
||||
export function parseConnectionsImport(rawJson: string): {
|
||||
preview: ArcadeImportPreview<NormalizedConnectionsPack>;
|
||||
report: ArcadeValidationReport;
|
||||
} {
|
||||
const trimmed = rawJson.trim();
|
||||
if (trimmed.includes("```") || !trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
||||
throw new ArcadeImportError("Paste one raw JSON object without Markdown fences or surrounding prose.");
|
||||
}
|
||||
|
||||
const repaired = parseAndRepairJson(trimmed);
|
||||
if (!repaired.success) {
|
||||
throw new ArcadeImportError(repaired.error ?? "The JSON could not be parsed.");
|
||||
}
|
||||
|
||||
const parsed = connectionsImportSchema.safeParse(repaired.data);
|
||||
if (!parsed.success) {
|
||||
throw new ArcadeImportError(
|
||||
"The Connections pack does not match schema version 1.",
|
||||
parsed.error.issues.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`)
|
||||
);
|
||||
}
|
||||
|
||||
const categoryKeys = parsed.data.content.map((group) => comparisonKey(group.category));
|
||||
if (new Set(categoryKeys).size !== categoryKeys.length) {
|
||||
throw new ArcadeImportError("Every group must have a unique category name.");
|
||||
}
|
||||
|
||||
const itemKeys = parsed.data.content.flatMap((group) => group.items.map(comparisonKey));
|
||||
if (new Set(itemKeys).size !== itemKeys.length) {
|
||||
throw new ArcadeImportError("Every tile must be unique across the entire board.");
|
||||
}
|
||||
|
||||
const usedGroupIds = new Set<string>();
|
||||
const normalized: NormalizedConnectionsPack = {
|
||||
schemaVersion: 1,
|
||||
type: "connections",
|
||||
name: clean(parsed.data.name),
|
||||
description: parsed.data.description ? clean(parsed.data.description) : undefined,
|
||||
settings: { ...parsed.data.settings },
|
||||
content: parsed.data.content.map((group, groupIndex) => {
|
||||
const fallbackId = `group-${groupIndex + 1}`;
|
||||
const groupId = safeId(group.id ?? fallbackId, fallbackId);
|
||||
if (usedGroupIds.has(groupId)) {
|
||||
throw new ArcadeImportError(`Group id “${groupId}” is duplicated.`);
|
||||
}
|
||||
usedGroupIds.add(groupId);
|
||||
return {
|
||||
id: groupId,
|
||||
category: clean(group.category),
|
||||
explanation: group.explanation.trim(),
|
||||
items: group.items.map((text, itemIndex) => ({
|
||||
id: `${groupId}-item-${itemIndex + 1}`,
|
||||
text: clean(text),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const warnings = findWarnings(normalized);
|
||||
const report = { wasRepaired: repaired.wasRepaired, warnings };
|
||||
return {
|
||||
report,
|
||||
preview: {
|
||||
gameType: "connections",
|
||||
name: normalized.name,
|
||||
description: normalized.description,
|
||||
categories: normalized.content.map((group) => group.category),
|
||||
itemCount: 16,
|
||||
wasRepaired: repaired.wasRepaired,
|
||||
warnings,
|
||||
normalized,
|
||||
source: repaired.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
34
src/lib/arcade/crosswordAttempt.test.ts
Normal file
34
src/lib/arcade/crosswordAttempt.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replayCrosswordAttempt } from "@/lib/arcade/crosswordAttempt";
|
||||
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
|
||||
import { crosswordTestPack } from "@/lib/arcade/crosswordTestData";
|
||||
|
||||
const settings = { size: "mini" as const, instantCheck: false, allowHints: true };
|
||||
|
||||
describe("Crossword attempt replay", () => {
|
||||
it("scores a perfect unassisted puzzle on the server", () => {
|
||||
const pack = crosswordTestPack();
|
||||
const layout = generateCrosswordLayout(pack, "mini", "perfect");
|
||||
const answers = Object.fromEntries(layout.entries.map((entry) => [entry.id, entry.answer]));
|
||||
const result = replayCrosswordAttempt(pack, "perfect", settings, answers, [], 120, false);
|
||||
expect(result.score).toBe(result.maxScore);
|
||||
expect(result.accuracy).toBe(1);
|
||||
expect(result.placedCount + result.omittedCount).toBe(80);
|
||||
});
|
||||
|
||||
it("applies check and reveal penalties and rejects tampered actions", () => {
|
||||
const pack = crosswordTestPack();
|
||||
const layout = generateCrosswordLayout(pack, "mini", "assisted");
|
||||
const entry = layout.entries[0];
|
||||
const answers = Object.fromEntries(layout.entries.map((item) => [item.id, item.answer]));
|
||||
const result = replayCrosswordAttempt(pack, "assisted", settings, answers, [
|
||||
{ type: "CHECK_WORD", entryId: entry.id, value: "WRONG", elapsedMs: 1000 },
|
||||
{ type: "REVEAL_LETTER", cellKey: entry.cellKeys[0], elapsedMs: 2000 },
|
||||
{ type: "ALTERNATE_CLUE", entryId: entry.id, elapsedMs: 3000 },
|
||||
], 30, false);
|
||||
const assistedWords = layout.cells.find((cell) => cell.key === entry.cellKeys[0])?.entryIds.length ?? 1;
|
||||
expect(result.score).toBe(result.maxScore - 5 - assistedWords * 10);
|
||||
expect(result.hintsUsed).toBe(2);
|
||||
expect(() => replayCrosswordAttempt(pack, "assisted", settings, answers, [{ type: "REVEAL_LETTER", cellKey: "999:999", elapsedMs: 1 }], 1, false)).toThrow("invalid letter reveal");
|
||||
});
|
||||
});
|
||||
116
src/lib/arcade/crosswordAttempt.ts
Normal file
116
src/lib/arcade/crosswordAttempt.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
|
||||
import type {
|
||||
CrosswordAction,
|
||||
CrosswordRoundResult,
|
||||
CrosswordSessionSettings,
|
||||
NormalizedCrosswordPack,
|
||||
} from "@/types/arcade";
|
||||
|
||||
function answerKey(value: string) {
|
||||
return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^A-Za-z]/g, "").toUpperCase();
|
||||
}
|
||||
|
||||
export function replayCrosswordAttempt(
|
||||
pack: NormalizedCrosswordPack,
|
||||
seed: string,
|
||||
settings: CrosswordSessionSettings,
|
||||
finalAnswers: Record<string, string>,
|
||||
actions: CrosswordAction[],
|
||||
durationSeconds: number,
|
||||
gaveUp: boolean
|
||||
): CrosswordRoundResult {
|
||||
const layout = generateCrosswordLayout(pack, settings.size, seed);
|
||||
const entryById = new Map(layout.entries.map((entry) => [entry.id, entry]));
|
||||
const cellByKey = new Map(layout.cells.map((cell) => [cell.key, cell]));
|
||||
const unknownAnswerId = Object.keys(finalAnswers).find((id) => !entryById.has(id));
|
||||
if (unknownAnswerId) throw new Error("An attempt contains an answer for an entry outside this layout.");
|
||||
|
||||
const revealedCells = new Set<string>();
|
||||
const revealedWords = new Set<string>();
|
||||
const alternateClues = new Set<string>();
|
||||
let incorrectChecks = 0;
|
||||
let hintsUsed = 0;
|
||||
|
||||
for (const action of actions) {
|
||||
if (!Number.isInteger(action.elapsedMs) || action.elapsedMs < 0 || action.elapsedMs > 86_400_000) {
|
||||
throw new Error("An attempt contains an invalid action timestamp.");
|
||||
}
|
||||
if (action.type === "REVEAL_LETTER") {
|
||||
if (!settings.allowHints || !cellByKey.has(action.cellKey)) throw new Error("An attempt contains an invalid letter reveal.");
|
||||
if (!revealedCells.has(action.cellKey)) hintsUsed += 1;
|
||||
revealedCells.add(action.cellKey);
|
||||
continue;
|
||||
}
|
||||
if (action.type === "CHECK_PUZZLE") {
|
||||
if (Object.keys(action.answers).some((id) => !entryById.has(id))) throw new Error("A puzzle check references an unknown entry.");
|
||||
continue;
|
||||
}
|
||||
const entry = entryById.get(action.entryId);
|
||||
if (!entry) throw new Error("An attempt action references an unknown entry.");
|
||||
if (action.type === "CHECK_WORD") {
|
||||
if (answerKey(action.value) !== entry.answer) incorrectChecks += 1;
|
||||
} else if (action.type === "ALTERNATE_CLUE") {
|
||||
if (!settings.allowHints) throw new Error("Hints were disabled for this attempt.");
|
||||
if (!alternateClues.has(entry.id)) hintsUsed += 1;
|
||||
alternateClues.add(entry.id);
|
||||
} else if (action.type === "REVEAL_WORD") {
|
||||
if (!settings.allowHints) throw new Error("Hints were disabled for this attempt.");
|
||||
if (!revealedWords.has(entry.id)) hintsUsed += 1;
|
||||
revealedWords.add(entry.id);
|
||||
entry.cellKeys.forEach((cellKey) => revealedCells.add(cellKey));
|
||||
}
|
||||
}
|
||||
|
||||
let score = 0;
|
||||
let accurateWords = 0;
|
||||
const placedResults = layout.entries.map((entry) => {
|
||||
const playerAnswer = answerKey(finalAnswers[entry.id] ?? "");
|
||||
const correct = playerAnswer === entry.answer || revealedWords.has(entry.id);
|
||||
const revealedLetterCount = entry.cellKeys.filter((cellKey) => revealedCells.has(cellKey)).length;
|
||||
if (correct && !revealedWords.has(entry.id)) {
|
||||
accurateWords += 1;
|
||||
score += Math.max(20, 100 - revealedLetterCount * 10);
|
||||
}
|
||||
return {
|
||||
entryId: entry.id,
|
||||
clue: entry.clue,
|
||||
answer: entry.displayAnswer,
|
||||
playerAnswer: finalAnswers[entry.id] ?? "",
|
||||
explanation: entry.explanation,
|
||||
correct,
|
||||
omitted: false,
|
||||
revealedLetters: revealedLetterCount,
|
||||
revealedWord: revealedWords.has(entry.id),
|
||||
alternateClueUsed: alternateClues.has(entry.id),
|
||||
};
|
||||
});
|
||||
score = Math.max(0, score - incorrectChecks * 5);
|
||||
|
||||
return {
|
||||
outcome: gaveUp ? "GAVE_UP" : "COMPLETED",
|
||||
score,
|
||||
maxScore: layout.entries.length * 100,
|
||||
accuracy: layout.entries.length === 0 ? 0 : accurateWords / layout.entries.length,
|
||||
durationSeconds,
|
||||
mistakes: incorrectChecks,
|
||||
hintsUsed,
|
||||
size: settings.size,
|
||||
placedCount: layout.entries.length,
|
||||
omittedCount: layout.omittedEntries.length,
|
||||
entries: [
|
||||
...placedResults,
|
||||
...layout.omittedEntries.map((entry) => ({
|
||||
entryId: entry.id,
|
||||
clue: entry.clue,
|
||||
answer: entry.displayAnswer,
|
||||
playerAnswer: "",
|
||||
explanation: entry.explanation,
|
||||
correct: false,
|
||||
omitted: true,
|
||||
revealedLetters: 0,
|
||||
revealedWord: false,
|
||||
alternateClueUsed: false,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue