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
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
|
# local SQLite study data
|
||||||
|
/dev.db
|
||||||
|
/dev.db-journal
|
||||||
|
/dev.db-shm
|
||||||
|
/dev.db-wal
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
|
|
|
||||||
353
AGENTS.md
353
AGENTS.md
|
|
@ -1,5 +1,350 @@
|
||||||
<!-- BEGIN:nextjs-agent-rules -->
|
# Study Desk - AI Contributor Guide
|
||||||
# This is NOT the Next.js you know
|
|
||||||
|
|
||||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
This file is the operating guide for AI agents and human contributors working in this repository.
|
||||||
<!-- END:nextjs-agent-rules -->
|
|
||||||
|
## 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-dom": "19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
"ts-fsrs": "^5.4.1",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -33,7 +34,8 @@
|
||||||
"eslint-config-next": "16.2.9",
|
"eslint-config-next": "16.2.9",
|
||||||
"prisma": "^7.8.0",
|
"prisma": "^7.8.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
|
|
@ -1349,6 +1351,16 @@
|
||||||
"node": ">=12.4.0"
|
"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": {
|
"node_modules/@phc/format": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
|
"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": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
|
|
@ -2042,6 +2341,17 @@
|
||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.13",
|
"version": "4.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
||||||
|
|
@ -2051,6 +2361,13 @@
|
||||||
"@types/ms": "*"
|
"@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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
|
|
@ -2110,6 +2427,7 @@
|
||||||
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
|
|
@ -2790,6 +3108,119 @@
|
||||||
"win32"
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.17.0",
|
"version": "8.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||||
|
|
@ -3040,6 +3471,16 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/ast-types-flow": {
|
||||||
"version": "0.0.8",
|
"version": "0.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
"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"
|
"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": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
|
@ -4061,6 +4512,13 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/es-object-atoms": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
|
|
@ -4555,6 +5013,16 @@
|
||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/esutils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||||
|
|
@ -4574,6 +5042,16 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expect-type": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/exsolve": {
|
"node_modules/exsolve": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz",
|
||||||
|
|
@ -4797,6 +5275,21 @@
|
||||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
|
@ -7675,6 +8168,20 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/ohash": {
|
||||||
"version": "2.0.11",
|
"version": "2.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
||||||
|
|
@ -8407,6 +8914,40 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/run-parallel": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
|
|
@ -8739,6 +9280,13 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/signal-exit": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
|
|
@ -8833,6 +9381,13 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/std-env": {
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||||
|
|
@ -9130,6 +9685,23 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinybench": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tinyexec": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
|
|
@ -9179,6 +9751,16 @@
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
|
@ -9225,6 +9807,15 @@
|
||||||
"typescript": ">=4.8.4"
|
"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": {
|
"node_modules/tsconfig-paths": {
|
||||||
"version": "3.15.0",
|
"version": "3.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
||||||
|
|
@ -9646,6 +10237,236 @@
|
||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|
@ -9750,6 +10571,23 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|
@ -23,6 +24,7 @@
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
"ts-fsrs": "^5.4.1",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -34,6 +36,7 @@
|
||||||
"eslint-config-next": "16.2.9",
|
"eslint-config-next": "16.2.9",
|
||||||
"prisma": "^7.8.0",
|
"prisma": "^7.8.0",
|
||||||
"tailwindcss": "^4",
|
"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)
|
sortOrder Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
decks Deck[]
|
decks Deck[]
|
||||||
quizSets QuizSet[]
|
quizSets QuizSet[]
|
||||||
materialGroups MaterialGroup[]
|
materialGroups MaterialGroup[]
|
||||||
|
spacedRepetitionSets SpacedRepetitionSet[]
|
||||||
|
arcadePacks ArcadePack[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Deck {
|
model Deck {
|
||||||
|
|
@ -27,12 +29,13 @@ model Deck {
|
||||||
sortOrder Int @default(0)
|
sortOrder Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||||
groupId String?
|
groupId String?
|
||||||
group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
|
group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
|
||||||
cards Flashcard[]
|
cards Flashcard[]
|
||||||
shareLink ShareLink?
|
shareLink ShareLink?
|
||||||
progress StudyProgress[]
|
progress StudyProgress[]
|
||||||
|
spacedRepetitionMemberships SpacedRepetitionSetDeck[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Flashcard {
|
model Flashcard {
|
||||||
|
|
@ -42,7 +45,8 @@ model Flashcard {
|
||||||
back String
|
back String
|
||||||
sortOrder Int @default(0)
|
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 {
|
model QuizSet {
|
||||||
|
|
@ -53,9 +57,9 @@ model QuizSet {
|
||||||
sortOrder Int @default(0)
|
sortOrder Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||||
groupId String?
|
groupId String?
|
||||||
group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
|
group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
|
||||||
questions Question[]
|
questions Question[]
|
||||||
attempts QuizAttempt[]
|
attempts QuizAttempt[]
|
||||||
shareLink ShareLink?
|
shareLink ShareLink?
|
||||||
|
|
@ -141,16 +145,121 @@ model Setting {
|
||||||
value String
|
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 {
|
model MaterialGroup {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
classId String
|
classId String
|
||||||
name String
|
name String
|
||||||
type String // "DECK" | "QUIZ"
|
type String // "DECK" | "QUIZ"
|
||||||
sortOrder Int @default(0)
|
sortOrder Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||||
decks Deck[]
|
decks Deck[]
|
||||||
quizSets QuizSet[]
|
quizSets QuizSet[]
|
||||||
shareLink ShareLink?
|
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 (
|
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 */}
|
{/* 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>
|
<div>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push(`/${classSlug}/flashcards`)}
|
onClick={() => router.push(`/${classSlug}/flashcards`)}
|
||||||
|
|
@ -103,7 +103,8 @@ export default function DeckStudyPage() {
|
||||||
</svg>
|
</svg>
|
||||||
Back to decks
|
Back to decks
|
||||||
</button>
|
</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 && (
|
{deck.description && (
|
||||||
<p className="text-sm text-text-secondary mt-1">{deck.description}</p>
|
<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">
|
<div className="flex items-center gap-2 md:gap-4 ml-auto">
|
||||||
<ShareMenu targetType="DECK" contentId={deck.id} classSlug={classSlug} />
|
<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
|
<button
|
||||||
onClick={() => setView("study")}
|
onClick={() => setView("study")}
|
||||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
useSortable,
|
useSortable,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import type { ReorderItem } from "@/types/study";
|
||||||
|
|
||||||
interface DeckItem {
|
interface DeckItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -65,7 +66,14 @@ function getProgressLabel(deck: DeckItem) {
|
||||||
return `${current}/${total}, ${modeLabel} · ${correctCount} ✓`;
|
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 { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: deck.id });
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
|
|
@ -76,8 +84,8 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
|
||||||
const progressLabel = getProgressLabel(deck);
|
const progressLabel = getProgressLabel(deck);
|
||||||
|
|
||||||
return (
|
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 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="p-4 flex flex-col flex-1 h-full">
|
<div className="flex h-full flex-1 flex-col p-5">
|
||||||
<div className="flex items-start gap-2">
|
<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">
|
<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">
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<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>}
|
{deck.description && <p className="text-sm text-text-secondary mb-3 line-clamp-2">{deck.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -99,9 +107,10 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pt-4 border-t border-border-light pl-7">
|
<div className="flex flex-col gap-2 border-t border-border-light pt-4 pl-7 sm:flex-row sm:items-center">
|
||||||
{deck.progress?.length ? (
|
<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">
|
<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
|
Continue
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -119,22 +128,26 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
|
||||||
>
|
>
|
||||||
Restart
|
Restart
|
||||||
</button>
|
</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">
|
<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
|
Study
|
||||||
</Link>
|
</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">
|
</div>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<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" />
|
<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>
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</button>
|
<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" />
|
||||||
<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>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</button>
|
||||||
<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" />
|
<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>
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</button>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -309,8 +322,6 @@ export default function FlashcardsPage() {
|
||||||
if (!activeDeck) return;
|
if (!activeDeck) return;
|
||||||
|
|
||||||
let targetGroupId: string | null = null;
|
let targetGroupId: string | null = null;
|
||||||
let targetIndex = 0;
|
|
||||||
|
|
||||||
const overContainerId = over.data.current?.sortable?.containerId;
|
const overContainerId = over.data.current?.sortable?.containerId;
|
||||||
if (overContainerId) {
|
if (overContainerId) {
|
||||||
targetGroupId = overContainerId === "uncategorized" ? null : overContainerId;
|
targetGroupId = overContainerId === "uncategorized" ? null : overContainerId;
|
||||||
|
|
@ -339,7 +350,7 @@ export default function FlashcardsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const affectedGroups = new Set([activeDeck.groupId, targetGroupId]);
|
const affectedGroups = new Set([activeDeck.groupId, targetGroupId]);
|
||||||
const updates: any[] = [];
|
const updates: ReorderItem[] = [];
|
||||||
|
|
||||||
affectedGroups.forEach(gId => {
|
affectedGroups.forEach(gId => {
|
||||||
const gItems = newItems.filter(d => d.groupId === 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);
|
const activeDeck = decks.find(d => d.id === activeId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
<div className="pb-20">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h2 className="text-xl font-semibold text-text-heading">Flashcard Decks</h2>
|
<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="flex items-center gap-3">
|
<div className="grid grid-cols-2 gap-3 sm:flex">
|
||||||
<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">
|
<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
|
Add Group
|
||||||
</button>
|
</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">
|
<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" />
|
<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>
|
</svg>
|
||||||
|
|
@ -387,7 +398,7 @@ export default function FlashcardsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isCreatingGroup && (
|
{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
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -407,8 +418,8 @@ export default function FlashcardsPage() {
|
||||||
|
|
||||||
{editingId && (
|
{editingId && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
|
<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">
|
<div className="w-full max-w-md rounded-3xl border border-border bg-bg-surface p-6 shadow-[var(--shadow-modal)]">
|
||||||
<h3 className="text-lg font-semibold text-text-heading mb-4">Edit Deck</h3>
|
<h3 className="editorial-title mb-4 text-2xl text-text-heading">Edit deck</h3>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editName}
|
value={editName}
|
||||||
|
|
@ -443,7 +454,7 @@ export default function FlashcardsPage() {
|
||||||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{groupedDecks.map(group => (
|
{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"}`}>
|
<div className={`flex items-center justify-between ${collapsedGroups[group.id] ? "" : "mb-4"}`}>
|
||||||
{editingGroupId === group.id ? (
|
{editingGroupId === group.id ? (
|
||||||
<div className="flex items-center gap-2">
|
<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"}>
|
<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>
|
<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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -484,7 +495,7 @@ export default function FlashcardsPage() {
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Uncategorized */}
|
{/* 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"}`}>
|
<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"}>
|
<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>
|
<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 (
|
return (
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="app-page">
|
||||||
{/* Dynamic Header & Tabs */}
|
{/* Dynamic class header */}
|
||||||
<ClassHeader classSlug={classSlug} className={classData.name} />
|
<ClassHeader className={classData.name} />
|
||||||
|
|
||||||
{/* Page content */}
|
{/* Page content */}
|
||||||
{props.children}
|
{props.children}
|
||||||
|
|
|
||||||
|
|
@ -124,9 +124,9 @@ export default function QuizStudyPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 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>
|
<div>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push(`/${classSlug}/quizzes`)}
|
onClick={() => router.push(`/${classSlug}/quizzes`)}
|
||||||
|
|
@ -142,7 +142,7 @@ export default function QuizStudyPage() {
|
||||||
onClick={() => setShowTopics(!showTopics)}
|
onClick={() => setShowTopics(!showTopics)}
|
||||||
className="flex items-center gap-2 group cursor-pointer text-left focus:outline-none mb-2"
|
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}
|
{quiz.name}
|
||||||
</h1>
|
</h1>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -180,7 +180,7 @@ export default function QuizStudyPage() {
|
||||||
<ShareMenu targetType="QUIZ" contentId={quiz.id} classSlug={classSlug} />
|
<ShareMenu targetType="QUIZ" contentId={quiz.id} classSlug={classSlug} />
|
||||||
|
|
||||||
{attempts.length > 0 && (
|
{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
|
<button
|
||||||
onClick={() => setView("history")}
|
onClick={() => setView("history")}
|
||||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
useSortable,
|
useSortable,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import type { ReorderItem } from "@/types/study";
|
||||||
|
|
||||||
interface QuizItem {
|
interface QuizItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -35,6 +36,12 @@ interface QuizItem {
|
||||||
groupId: string | null;
|
groupId: string | null;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
_count: { questions: number; attempts: number };
|
_count: { questions: number; attempts: number };
|
||||||
|
progress: Array<{
|
||||||
|
mode: string;
|
||||||
|
currentIndex: number;
|
||||||
|
orderJson: string;
|
||||||
|
answersJson: string | null;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MaterialGroup {
|
interface MaterialGroup {
|
||||||
|
|
@ -45,7 +52,14 @@ interface MaterialGroup {
|
||||||
|
|
||||||
const cache: Record<string, { quizzes: QuizItem[]; groups: 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 { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: quiz.id });
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
|
|
@ -54,8 +68,8 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 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="p-4 flex flex-col flex-1 h-full">
|
<div className="flex h-full flex-1 flex-col p-5">
|
||||||
<div className="flex items-start gap-2">
|
<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">
|
<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">
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<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>}
|
{quiz.description && <p className="text-sm text-text-secondary mb-3 line-clamp-2">{quiz.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -79,20 +93,48 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-border-light pl-7">
|
<div className="mt-4 flex flex-col gap-2 border-t border-border-light pt-4 pl-7 sm:flex-row sm:items-center">
|
||||||
<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">
|
<div className="flex w-full min-w-0 gap-2 sm:flex-1">
|
||||||
Start Quiz
|
{quiz.progress?.length ? (
|
||||||
</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">
|
<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">
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
Continue
|
||||||
<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" />
|
</Link>
|
||||||
</svg>
|
<button
|
||||||
</button>
|
onClick={async () => {
|
||||||
<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">
|
if (!confirm("Start over from the beginning? This will clear your current progress for this quiz.")) return;
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
await fetch("/api/progress", {
|
||||||
<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" />
|
method: "DELETE",
|
||||||
</svg>
|
headers: { "Content-Type": "application/json" },
|
||||||
</button>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -278,8 +320,6 @@ export default function QuizzesPage() {
|
||||||
if (!activeQuiz) return;
|
if (!activeQuiz) return;
|
||||||
|
|
||||||
let targetGroupId: string | null = null;
|
let targetGroupId: string | null = null;
|
||||||
let targetIndex = 0;
|
|
||||||
|
|
||||||
// Check if over a group container directly
|
// Check if over a group container directly
|
||||||
const overContainerId = over.data.current?.sortable?.containerId;
|
const overContainerId = over.data.current?.sortable?.containerId;
|
||||||
if (overContainerId) {
|
if (overContainerId) {
|
||||||
|
|
@ -311,7 +351,7 @@ export default function QuizzesPage() {
|
||||||
|
|
||||||
// Re-calculate sortOrder for the affected groups to persist
|
// Re-calculate sortOrder for the affected groups to persist
|
||||||
const affectedGroups = new Set([activeQuiz.groupId, targetGroupId]);
|
const affectedGroups = new Set([activeQuiz.groupId, targetGroupId]);
|
||||||
const updates: any[] = [];
|
const updates: ReorderItem[] = [];
|
||||||
|
|
||||||
affectedGroups.forEach(gId => {
|
affectedGroups.forEach(gId => {
|
||||||
const gItems = newItems.filter(q => q.groupId === 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);
|
const activeQuiz = quizzes.find(q => q.id === activeId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
<div className="pb-20">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h2 className="text-xl font-semibold text-text-heading">Quizzes</h2>
|
<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="flex items-center gap-3">
|
<div className="grid grid-cols-2 gap-3 sm:flex">
|
||||||
<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">
|
<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
|
Add Group
|
||||||
</button>
|
</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">
|
<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" />
|
<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>
|
</svg>
|
||||||
|
|
@ -361,7 +401,7 @@ export default function QuizzesPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isCreatingGroup && (
|
{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
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -382,8 +422,8 @@ export default function QuizzesPage() {
|
||||||
{/* Editing quiz modal/inline - simplified as a modal-like overlay for simplicity when editing */}
|
{/* Editing quiz modal/inline - simplified as a modal-like overlay for simplicity when editing */}
|
||||||
{editingId && (
|
{editingId && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
|
<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">
|
<div className="w-full max-w-md rounded-3xl border border-border bg-bg-surface p-6 shadow-[var(--shadow-modal)]">
|
||||||
<h3 className="text-lg font-semibold text-text-heading mb-4">Edit Quiz</h3>
|
<h3 className="editorial-title mb-4 text-2xl text-text-heading">Edit quiz</h3>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editName}
|
value={editName}
|
||||||
|
|
@ -418,7 +458,7 @@ export default function QuizzesPage() {
|
||||||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{groupedQuizzes.map(group => (
|
{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"}`}>
|
<div className={`flex items-center justify-between ${collapsedGroups[group.id] ? "" : "mb-4"}`}>
|
||||||
{editingGroupId === group.id ? (
|
{editingGroupId === group.id ? (
|
||||||
<div className="flex items-center gap-2">
|
<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"}>
|
<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>
|
<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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -459,7 +499,7 @@ export default function QuizzesPage() {
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Uncategorized */}
|
{/* 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"}`}>
|
<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"}>
|
<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>
|
<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 (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main className="flex-1">{children}</main>
|
<main className="min-h-screen md:ml-60">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,22 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { ActivityBanner } from "@/components/activity/ActivityBanner";
|
||||||
|
|
||||||
interface ClassItem {
|
interface ClassItem {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
_count: { decks: number; quizSets: number };
|
_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() {
|
export default function HomePage() {
|
||||||
const [classes, setClasses] = useState<ClassItem[]>([]);
|
const [classes, setClasses] = useState<ClassItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -20,256 +27,112 @@ export default function HomePage() {
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
const [savingEdit, setSavingEdit] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { fetchClasses(); }, []);
|
||||||
fetchClasses();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function fetchClasses() {
|
async function fetchClasses() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/classes");
|
const res = await fetch("/api/classes");
|
||||||
const data = await res.json();
|
setClasses(await res.json());
|
||||||
setClasses(data);
|
} finally { setLoading(false); }
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate(e: React.FormEvent) {
|
async function handleCreate(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newName.trim()) return;
|
if (!newName.trim()) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/classes", {
|
const res = await fetch("/api/classes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newName.trim() }) });
|
||||||
method: "POST",
|
if (res.ok) { setNewName(""); setShowCreate(false); fetchClasses(); }
|
||||||
headers: { "Content-Type": "application/json" },
|
} finally { setCreating(false); }
|
||||||
body: JSON.stringify({ name: newName.trim() }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
setNewName("");
|
|
||||||
setShowCreate(false);
|
|
||||||
fetchClasses();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setCreating(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string, name: string) {
|
async function handleDelete(id: string, name: string) {
|
||||||
if (!confirm(`Delete "${name}" and all its decks and quizzes?`)) return;
|
if (!confirm(`Delete "${name}" and all its decks and quizzes?`)) return;
|
||||||
|
|
||||||
await fetch(`/api/classes/${id}`, { method: "DELETE" });
|
await fetch(`/api/classes/${id}`, { method: "DELETE" });
|
||||||
fetchClasses();
|
fetchClasses();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveEdit(id: string) {
|
async function handleSaveEdit(id: string) {
|
||||||
if (!editName.trim()) {
|
if (!editName.trim()) { setEditingId(null); return; }
|
||||||
setEditingId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSavingEdit(true);
|
setSavingEdit(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/classes/${id}`, {
|
const res = await fetch(`/api/classes/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: editName.trim() }) });
|
||||||
method: "PATCH",
|
if (res.ok) { setEditingId(null); fetchClasses(); }
|
||||||
headers: { "Content-Type": "application/json" },
|
} finally { setSavingEdit(false); }
|
||||||
body: JSON.stringify({ name: editName.trim() }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setEditingId(null);
|
|
||||||
fetchClasses();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setSavingEdit(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="app-page">
|
||||||
{/* Header */}
|
<ActivityBanner onNewClass={() => setShowCreate(true)} />
|
||||||
<div className="flex items-center justify-between mb-8">
|
|
||||||
|
{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>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-text-heading">Your Classes</h1>
|
<p className="text-xs font-bold uppercase tracking-[0.18em] text-primary">Study spaces</p>
|
||||||
<p className="text-text-secondary mt-1">
|
<h2 className="editorial-title mt-1 text-3xl text-text-heading">Your classes</h2>
|
||||||
Select a class to study flashcards or take quizzes
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<p className="hidden text-sm text-text-muted sm:block">Flashcards and quizzes, organized by class.</p>
|
||||||
onClick={() => setShowCreate(!showCreate)}
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 shadow-sm cursor-pointer"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
||||||
</svg>
|
|
||||||
Add Class
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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 && (
|
{loading && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => <div key={i} className="h-52 animate-subtle-pulse rounded-2xl border border-border-light bg-bg-surface" />)}
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty state */}
|
|
||||||
{!loading && classes.length === 0 && (
|
{!loading && classes.length === 0 && (
|
||||||
<div className="text-center py-16">
|
<div className="paper-grid rounded-[2rem] border border-dashed border-border bg-bg-surface px-6 py-16 text-center">
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-bg-callout mb-4">
|
<div className="mx-auto mb-5 grid h-14 w-14 place-items-center rounded-2xl bg-bg-callout text-2xl text-primary">+</div>
|
||||||
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<h2 className="editorial-title text-2xl text-text-heading">Your desk is ready</h2>
|
||||||
<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" />
|
<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>
|
||||||
</svg>
|
<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>
|
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Class grid */}
|
|
||||||
{!loading && classes.length > 0 && (
|
{!loading && classes.length > 0 && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{classes.map((cls) => (
|
{classes.map((cls, index) => (
|
||||||
<div
|
<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)]">
|
||||||
key={cls.id}
|
<div className="h-1.5" style={{ backgroundColor: accents[index % accents.length] }} />
|
||||||
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 min-h-48 p-5 sm:p-6">
|
||||||
>
|
<div className="mb-8 flex items-start justify-between gap-4">
|
||||||
<Link
|
<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>
|
||||||
href={`/${cls.slug}`}
|
<span className="text-xl text-text-muted transition-transform group-hover:translate-x-1" aria-hidden>→</span>
|
||||||
className="block p-6"
|
</div>
|
||||||
>
|
|
||||||
{editingId === cls.id ? (
|
{editingId === cls.id ? (
|
||||||
<input
|
<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" />
|
||||||
type="text"
|
) : <h3 className="text-xl font-extrabold text-text-heading group-hover:text-primary">{cls.name}</h3>}
|
||||||
value={editName}
|
<div className="mt-3 flex flex-wrap gap-2 text-xs font-semibold text-text-secondary">
|
||||||
onClick={(e) => {
|
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1">{cls._count.decks} {cls._count.decks === 1 ? "deck" : "decks"}</span>
|
||||||
e.preventDefault();
|
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1">{cls._count.quizSets} {cls._count.quizSets === 1 ? "quiz" : "quizzes"}</span>
|
||||||
e.stopPropagation();
|
{cls.spacedRepetitionReadyCards > 0 && (
|
||||||
}}
|
<span
|
||||||
onChange={(e) => setEditName(e.target.value)}
|
className="rounded-full bg-error-bg px-2.5 py-1 text-error"
|
||||||
onKeyDown={(e) => {
|
title={`${cls.spacedRepetitionDueCards} due, ${cls.spacedRepetitionLearningCards} learning, ${cls.spacedRepetitionNewCards} new`}
|
||||||
if (e.key === "Enter") {
|
>
|
||||||
e.preventDefault();
|
{cls.spacedRepetitionReadyCards} {cls.spacedRepetitionReadyCards === 1 ? "card" : "cards"} ready
|
||||||
e.stopPropagation();
|
</span>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
<div className="absolute right-3 top-4 flex gap-1 rounded-xl border border-border-light bg-bg-surface p-1 opacity-100 shadow-sm sm:opacity-0 sm:group-hover:opacity-100">
|
||||||
{/* Actions */}
|
<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>
|
||||||
<div className="absolute top-3 right-3 flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-all duration-200">
|
<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>
|
||||||
{/* Delete button */}
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDelete(cls.id, cls.name);
|
|
||||||
}}
|
|
||||||
className="p-1.5 rounded-lg text-text-muted hover:text-error hover:bg-error-bg transition-colors cursor-pointer"
|
|
||||||
title="Delete class"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/* Edit button */}
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setEditingId(cls.id);
|
|
||||||
setEditName(cls.name);
|
|
||||||
}}
|
|
||||||
className="p-1.5 rounded-lg text-text-muted hover:text-primary hover:bg-primary/10 transition-colors cursor-pointer"
|
|
||||||
title="Rename class"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { loginSchema } from "@/lib/validation/authSchemas";
|
||||||
import { createSession } from "@/lib/auth";
|
|
||||||
import { checkRateLimit } from "@/lib/rateLimiter";
|
import { checkRateLimit } from "@/lib/rateLimiter";
|
||||||
|
import { login } from "@/services/authService";
|
||||||
// Progressive lockout thresholds from Section 4
|
|
||||||
function getLockoutDuration(failedAttempts: number): number {
|
|
||||||
if (failedAttempts >= 20) return 24 * 60 * 60 * 1000; // 24 hours
|
|
||||||
if (failedAttempts >= 15) return 30 * 60 * 1000; // 30 minutes
|
|
||||||
if (failedAttempts >= 10) return 5 * 60 * 1000; // 5 minutes
|
|
||||||
if (failedAttempts >= 5) return 1 * 60 * 1000; // 1 minute
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
// Rate limit by IP
|
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||||
const forwarded = request.headers.get("x-forwarded-for");
|
if (!checkRateLimit(`login:${ip}`).allowed) {
|
||||||
const ip = forwarded?.split(",")[0]?.trim() ?? "unknown";
|
|
||||||
const rateCheck = checkRateLimit(ip);
|
|
||||||
|
|
||||||
if (!rateCheck.allowed) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Too many requests. Try again shortly." },
|
{ error: "Too many requests. Try again shortly." },
|
||||||
{ status: 429 }
|
{ status: 429 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json().catch(() => null);
|
const parsed = loginSchema.safeParse(await request.json().catch(() => null));
|
||||||
if (!body?.password || typeof body.password !== "string") {
|
if (!parsed.success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Password is required" },
|
{ error: parsed.error.issues[0]?.message ?? "Invalid request" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check lockout state
|
const result = await login(parsed.data.password);
|
||||||
let security = await prisma.authSecurity.findUnique({ where: { id: 1 } });
|
if (!result.ok) {
|
||||||
if (!security) {
|
return NextResponse.json({ error: result.error }, { status: result.status });
|
||||||
security = await prisma.authSecurity.create({ data: { id: 1 } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (security.lockedUntil && security.lockedUntil > new Date()) {
|
|
||||||
const remainingMs =
|
|
||||||
security.lockedUntil.getTime() - Date.now();
|
|
||||||
const remainingMin = Math.ceil(remainingMs / 60000);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: `Account locked. Try again in ${remainingMin} minute${remainingMin !== 1 ? "s" : ""}.`,
|
|
||||||
},
|
|
||||||
{ status: 423 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify password
|
|
||||||
const setting = await prisma.setting.findUnique({
|
|
||||||
where: { key: "admin_password_hash" },
|
|
||||||
});
|
|
||||||
|
|
||||||
let isValid = false;
|
|
||||||
|
|
||||||
if (!setting) {
|
|
||||||
// Initial setup: hash and save the new password
|
|
||||||
const argon2 = await import("argon2");
|
|
||||||
const newHash = await argon2.hash(body.password);
|
|
||||||
await prisma.setting.create({
|
|
||||||
data: {
|
|
||||||
key: "admin_password_hash",
|
|
||||||
value: newHash,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
isValid = true;
|
|
||||||
} else {
|
|
||||||
// Verify existing password
|
|
||||||
const passwordHash = setting.value;
|
|
||||||
try {
|
|
||||||
const argon2 = await import("argon2");
|
|
||||||
isValid = await argon2.verify(passwordHash, body.password);
|
|
||||||
} catch {
|
|
||||||
isValid = body.password === passwordHash;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
const newFailedAttempts = security.failedAttempts + 1;
|
|
||||||
const lockoutMs = getLockoutDuration(newFailedAttempts);
|
|
||||||
const lockedUntil = lockoutMs > 0 ? new Date(Date.now() + lockoutMs) : null;
|
|
||||||
|
|
||||||
await prisma.authSecurity.update({
|
|
||||||
where: { id: 1 },
|
|
||||||
data: {
|
|
||||||
failedAttempts: newFailedAttempts,
|
|
||||||
lockedUntil,
|
|
||||||
lastAttemptAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid password" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Success — reset lockout, create session
|
|
||||||
await prisma.authSecurity.update({
|
|
||||||
where: { id: 1 },
|
|
||||||
data: {
|
|
||||||
failedAttempts: 0,
|
|
||||||
lockedUntil: null,
|
|
||||||
lastAttemptAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await createSession();
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { reorderRequestSchema } from "@/lib/validation/reorderSchemas";
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const parsed = reorderRequestSchema.safeParse(await request.json());
|
||||||
const { items } = body;
|
if (!parsed.success) {
|
||||||
|
|
||||||
if (!Array.isArray(items)) {
|
|
||||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { items } = parsed.data;
|
||||||
|
|
||||||
// items should be an array of { id, sortOrder, groupId }
|
// items should be an array of { id, sortOrder, groupId }
|
||||||
// Using a transaction to perform all updates
|
// Using a transaction to perform all updates
|
||||||
await prisma.$transaction(
|
await prisma.$transaction(
|
||||||
items.map((item: any) =>
|
items.map((item) =>
|
||||||
prisma.deck.update({
|
prisma.deck.update({
|
||||||
where: { id: item.id },
|
where: { id: item.id },
|
||||||
data: {
|
data: {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
|
|
@ -10,7 +11,7 @@ export async function PATCH(
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, sortOrder } = body;
|
const { name, sortOrder } = body;
|
||||||
|
|
||||||
const updateData: any = {};
|
const updateData: Prisma.MaterialGroupUpdateInput = {};
|
||||||
if (name !== undefined) updateData.name = name;
|
if (name !== undefined) updateData.name = name;
|
||||||
if (sortOrder !== undefined) updateData.sortOrder = sortOrder;
|
if (sortOrder !== undefined) updateData.sortOrder = sortOrder;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
@ -14,7 +15,7 @@ export async function GET(request: NextRequest) {
|
||||||
return NextResponse.json({ error: "Invalid type" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid type" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause: any = { classId };
|
const whereClause: Prisma.MaterialGroupWhereInput = { classId };
|
||||||
if (type) {
|
if (type) {
|
||||||
whereClause.type = type;
|
whereClause.type = type;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,20 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { reorderRequestSchema } from "@/lib/validation/reorderSchemas";
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const parsed = reorderRequestSchema.safeParse(await request.json());
|
||||||
const { items } = body;
|
if (!parsed.success) {
|
||||||
|
|
||||||
if (!Array.isArray(items)) {
|
|
||||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { items } = parsed.data;
|
||||||
|
|
||||||
// items should be an array of { id, sortOrder, groupId }
|
// items should be an array of { id, sortOrder, groupId }
|
||||||
// Using a transaction to perform all updates
|
// Using a transaction to perform all updates
|
||||||
await prisma.$transaction(
|
await prisma.$transaction(
|
||||||
items.map((item: any) =>
|
items.map((item) =>
|
||||||
prisma.quizSet.update({
|
prisma.quizSet.update({
|
||||||
where: { id: item.id },
|
where: { id: item.id },
|
||||||
data: {
|
data: {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,21 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import * as settingsService from "@/services/settingsService";
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const type = getInstructionType(request);
|
||||||
const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
|
if (!type) return NextResponse.json({ error: "Invalid instruction type" }, { status: 400 });
|
||||||
const instructions = await settingsService.getLlmInstructions(type);
|
const instructions = await settingsService.getLlmInstructions(type);
|
||||||
return NextResponse.json({ value: instructions });
|
return NextResponse.json({ value: instructions });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const type = getInstructionType(request);
|
||||||
const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
|
if (!type) return NextResponse.json({ error: "Invalid instruction type" }, { status: 400 });
|
||||||
const body = await request.json().catch(() => null);
|
const body = await request.json().catch(() => null);
|
||||||
|
|
||||||
if (!body || typeof body.value !== "string") {
|
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 {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--theme-bg-base: #f0f4f2;
|
--theme-bg-base: #f5f2ea;
|
||||||
--theme-bg-surface: #ffffff;
|
--theme-bg-surface: #fffdf8;
|
||||||
--theme-bg-surface-alt: #f4f7f5;
|
--theme-bg-surface-alt: #eeeae0;
|
||||||
--theme-bg-callout: #e8f4f0;
|
--theme-bg-callout: #ebe9ff;
|
||||||
--theme-primary: #2a7d7d;
|
--theme-primary: #4f46e5;
|
||||||
--theme-primary-hover: #1f6363;
|
--theme-primary-hover: #3f37c9;
|
||||||
--theme-primary-light: #3a9e9e;
|
--theme-primary-light: #756ef0;
|
||||||
--theme-text-heading: #1a2f2f;
|
--theme-accent: #f97360;
|
||||||
--theme-text-body: #2d4a4a;
|
--theme-text-heading: #172033;
|
||||||
--theme-text-secondary: #4a6a6a;
|
--theme-text-body: #344054;
|
||||||
--theme-text-muted: #7a9a9a;
|
--theme-text-secondary: #667085;
|
||||||
--theme-border: #c8d8d0;
|
--theme-text-muted: #8b91a0;
|
||||||
--theme-border-light: #dce8e2;
|
--theme-border: #d7d2c7;
|
||||||
--theme-success: #2d8a4e;
|
--theme-border-light: #e7e2d8;
|
||||||
--theme-success-bg: #e8f5ed;
|
--theme-success: #25845f;
|
||||||
--theme-error: #c44c4c;
|
--theme-success-bg: #e3f4ec;
|
||||||
--theme-error-bg: #fce8e8;
|
--theme-error: #c44747;
|
||||||
--theme-badge-bg: #e0eeea;
|
--theme-error-bg: #fae8e5;
|
||||||
--theme-badge-text: #2a6a5a;
|
--theme-badge-bg: #e8e5ff;
|
||||||
--theme-danger: #dc3545;
|
--theme-badge-text: #4840bb;
|
||||||
--theme-danger-hover: #c82333;
|
--theme-danger: #c44747;
|
||||||
|
--theme-danger-hover: #a93636;
|
||||||
--theme-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
--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 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
|
--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 20px 60px rgba(0, 0, 0, 0.15), 0 8px 20px rgba(0, 0, 0, 0.1);
|
--theme-shadow-modal: 0 28px 80px rgba(23, 32, 51, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root.dark {
|
:root.dark {
|
||||||
--theme-bg-base: #121212;
|
--theme-bg-base: #0d1117;
|
||||||
--theme-bg-surface: #1e1e1e;
|
--theme-bg-surface: #151b23;
|
||||||
--theme-bg-surface-alt: #2d2d2d;
|
--theme-bg-surface-alt: #1c2430;
|
||||||
--theme-bg-callout: #1a2723;
|
--theme-bg-callout: #25234a;
|
||||||
--theme-primary: #4ab2b2;
|
--theme-primary: #8b83ff;
|
||||||
--theme-primary-hover: #5ec7c7;
|
--theme-primary-hover: #a49eff;
|
||||||
--theme-primary-light: #6cd1d1;
|
--theme-primary-light: #b2adff;
|
||||||
--theme-text-heading: #f9fafb;
|
--theme-accent: #ff8a75;
|
||||||
--theme-text-body: #e5e7eb;
|
--theme-text-heading: #f6f3ec;
|
||||||
--theme-text-secondary: #9ca3af;
|
--theme-text-body: #d8dde6;
|
||||||
--theme-text-muted: #6b7280;
|
--theme-text-secondary: #aab3c0;
|
||||||
--theme-border: #374151;
|
--theme-text-muted: #7f8998;
|
||||||
--theme-border-light: #2d3748;
|
--theme-border: #354050;
|
||||||
--theme-success: #4ade80;
|
--theme-border-light: #293342;
|
||||||
--theme-success-bg: #064e3b;
|
--theme-success: #58c596;
|
||||||
--theme-error: #f87171;
|
--theme-success-bg: #173c30;
|
||||||
--theme-error-bg: #7f1d1d;
|
--theme-error: #ff8177;
|
||||||
--theme-badge-bg: #1f2937;
|
--theme-error-bg: #472424;
|
||||||
--theme-badge-text: #5ec7c7;
|
--theme-badge-bg: #2d2959;
|
||||||
--theme-danger: #ef4444;
|
--theme-badge-text: #bbb7ff;
|
||||||
--theme-danger-hover: #f87171;
|
--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);
|
border-color: var(--theme-border-light);
|
||||||
--theme-shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.5), 0 8px 20px rgba(0, 0, 0, 0.3);
|
}
|
||||||
|
|
||||||
|
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 {
|
@theme inline {
|
||||||
/* ── Sage / Teal palette mapped from CSS variables ── */
|
|
||||||
--color-bg-base: var(--theme-bg-base);
|
--color-bg-base: var(--theme-bg-base);
|
||||||
--color-bg-surface: var(--theme-bg-surface);
|
--color-bg-surface: var(--theme-bg-surface);
|
||||||
--color-bg-surface-alt: var(--theme-bg-surface-alt);
|
--color-bg-surface-alt: var(--theme-bg-surface-alt);
|
||||||
|
|
@ -67,6 +107,7 @@
|
||||||
--color-primary: var(--theme-primary);
|
--color-primary: var(--theme-primary);
|
||||||
--color-primary-hover: var(--theme-primary-hover);
|
--color-primary-hover: var(--theme-primary-hover);
|
||||||
--color-primary-light: var(--theme-primary-light);
|
--color-primary-light: var(--theme-primary-light);
|
||||||
|
--color-accent: var(--theme-accent);
|
||||||
--color-text-heading: var(--theme-text-heading);
|
--color-text-heading: var(--theme-text-heading);
|
||||||
--color-text-body: var(--theme-text-body);
|
--color-text-body: var(--theme-text-body);
|
||||||
--color-text-secondary: var(--theme-text-secondary);
|
--color-text-secondary: var(--theme-text-secondary);
|
||||||
|
|
@ -81,123 +122,603 @@
|
||||||
--color-badge-text: var(--theme-badge-text);
|
--color-badge-text: var(--theme-badge-text);
|
||||||
--color-danger: var(--theme-danger);
|
--color-danger: var(--theme-danger);
|
||||||
--color-danger-hover: var(--theme-danger-hover);
|
--color-danger-hover: var(--theme-danger-hover);
|
||||||
|
--font-sans: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
|
||||||
/* Typography */
|
--font-serif: var(--font-newsreader), ui-serif, Georgia, serif;
|
||||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
|
||||||
|
|
||||||
/* Shadows */
|
|
||||||
--shadow-card: var(--theme-shadow-card);
|
--shadow-card: var(--theme-shadow-card);
|
||||||
--shadow-card-hover: var(--theme-shadow-card-hover);
|
--shadow-card-hover: var(--theme-shadow-card-hover);
|
||||||
--shadow-modal: var(--theme-shadow-modal);
|
--shadow-modal: var(--theme-shadow-modal);
|
||||||
|
--ease-spring: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
/* Animations */
|
|
||||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Base Styles ── */
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
body {
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
background: var(--color-bg-base);
|
::-webkit-scrollbar-thumb { background: var(--color-border); border: 2px solid transparent; border-radius: 999px; background-clip: padding-box; }
|
||||||
color: var(--color-text-body);
|
|
||||||
font-family: var(--font-sans);
|
.app-page {
|
||||||
-webkit-font-smoothing: antialiased;
|
width: 100%;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
max-width: 1280px;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: 2rem clamp(1rem, 3vw, 3rem) 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Scrollbar Styling ── */
|
.editorial-title {
|
||||||
::-webkit-scrollbar {
|
font-family: var(--font-newsreader), Georgia, serif;
|
||||||
width: 6px;
|
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;
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
.connections-hub.connections-world::before,
|
||||||
background: var(--color-border);
|
.connections-stage.connections-world::before { display: none; }
|
||||||
border-radius: 3px;
|
|
||||||
}
|
.connections-world::before {
|
||||||
::-webkit-scrollbar-thumb:hover {
|
content: "";
|
||||||
background: var(--color-text-muted);
|
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 ── */
|
.connections-world .editorial-title {
|
||||||
.perspective-1000 {
|
font-family: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
|
||||||
perspective: 1000px;
|
font-weight: 850;
|
||||||
|
letter-spacing: -.045em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preserve-3d {
|
.connections-hub-hero,
|
||||||
transform-style: preserve-3d;
|
.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 {
|
.connections-hub-hero {
|
||||||
backface-visibility: hidden;
|
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 {
|
.connections-pack-card {
|
||||||
transform: rotateY(180deg);
|
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 {
|
.connections-pack-card:hover {
|
||||||
transform: rotateX(180deg);
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(255, 209, 102, .48);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Swipe animations ── */
|
.connections-pack-card.is-active {
|
||||||
@keyframes swipeLeft {
|
border-color: #ffd166;
|
||||||
0% { transform: translateX(0) rotate(0deg); opacity: 1; }
|
background: linear-gradient(120deg, rgba(65, 72, 132, .9), rgba(38, 55, 96, .9));
|
||||||
100% { transform: translateX(-40px) rotate(-8deg); opacity: 0; }
|
box-shadow: 0 0 0 1px rgba(255,209,102,.25), 0 18px 45px rgba(3,9,24,.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes swipeRight {
|
.connections-primary-button {
|
||||||
0% { transform: translateX(0) rotate(0deg); opacity: 1; }
|
color: #172033;
|
||||||
100% { transform: translateX(40px) rotate(8deg); opacity: 0; }
|
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 {
|
.connections-primary-button:not(:disabled):hover {
|
||||||
animation: swipeLeft 0.4s var(--ease-spring) forwards;
|
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 {
|
.connections-secondary-button {
|
||||||
animation: swipeRight 0.4s var(--ease-spring) forwards;
|
color: #dbe7ff;
|
||||||
|
border: 1px solid #52668d;
|
||||||
|
background: rgba(24, 36, 67, .82);
|
||||||
|
transition: transform .18s var(--ease-spring), background .18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Modal animation ── */
|
.connections-secondary-button:not(:disabled):hover { transform: translateY(-2px); background: #2c3d68; }
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; }
|
.connections-tile {
|
||||||
to { opacity: 1; }
|
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 {
|
.connections-tile:hover { transform: translateY(-3px); box-shadow: 0 9px 0 #aebbd2, 0 16px 30px rgba(2,8,23,.28), inset 0 1px #fff; }
|
||||||
from { opacity: 0; transform: translateY(20px) scale(0.98); }
|
.connections-tile.is-selected {
|
||||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
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 {
|
.connections-group,
|
||||||
animation: fadeIn 0.2s ease-out;
|
.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 {
|
.connections-completion {
|
||||||
animation: slideUp 0.3s var(--ease-spring);
|
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 connectionsTileIn { from { opacity: 0; transform: translateY(16px) scale(.94); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||||
@keyframes subtlePulse {
|
@keyframes connectionsTileSelect { 0% { transform: scale(1); } 55% { transform: translateY(3px) scale(.94); } 100% { transform: translateY(2px) scale(.975); } }
|
||||||
0%, 100% { opacity: 1; }
|
@keyframes connectionsShake { 0%,100% { transform: translateX(0); } 20% { transform: translateX(-9px); } 40% { transform: translateX(8px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(3px); } }
|
||||||
50% { opacity: 0.7; }
|
@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 {
|
.crossword-world {
|
||||||
animation: subtlePulse 2s ease-in-out infinite;
|
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: .5rem; color: var(--color-text-heading); }
|
||||||
.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: 650; margin-bottom: .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: 650; margin-bottom: .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: .75rem; line-height: 1.72; }
|
||||||
.markdown-content p { margin-bottom: 0.75rem; line-height: 1.7; }
|
.markdown-content ul { list-style: disc; padding-left: 1.35rem; margin-bottom: .75rem; }
|
||||||
.markdown-content ul { list-style: disc inside; padding-left: 0; margin-bottom: 0.75rem; text-align: inherit; }
|
.markdown-content ol { list-style: decimal; padding-left: 1.35rem; margin-bottom: .75rem; }
|
||||||
.markdown-content ol { list-style: decimal inside; padding-left: 0; margin-bottom: 0.75rem; text-align: inherit; }
|
.markdown-content li { margin-bottom: .25rem; line-height: 1.65; }
|
||||||
.markdown-content li { margin-bottom: 0.25rem; line-height: 1.6; }
|
.markdown-content strong { font-weight: 700; color: var(--color-text-heading); }
|
||||||
.markdown-content strong { font-weight: 600; 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 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: .75rem; }
|
||||||
.markdown-content table { border-collapse: collapse; width: 100%; margin-bottom: 0.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 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: .6rem .8rem; border: 1px solid var(--color-border-light); }
|
||||||
.markdown-content td { padding: 0.5rem 0.75rem; border: 1px solid var(--color-border-light); }
|
|
||||||
.markdown-content *:last-child { margin-bottom: 0; }
|
.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 type { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Manrope, Newsreader } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const inter = Inter({
|
const manrope = Manrope({
|
||||||
variable: "--font-inter",
|
variable: "--font-manrope",
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const newsreader = Newsreader({
|
||||||
|
variable: "--font-newsreader",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Study App",
|
title: "Study Desk",
|
||||||
description: "Practice exams and quizzes",
|
description: "A focused workspace for flashcards and practice quizzes",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|
@ -19,13 +25,16 @@ export default function RootLayout({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
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>
|
<head>
|
||||||
<script
|
<script
|
||||||
|
id="theme-init"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `
|
__html: `
|
||||||
try {
|
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')
|
document.documentElement.classList.add('dark')
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.classList.remove('dark')
|
document.documentElement.classList.remove('dark')
|
||||||
|
|
@ -35,7 +44,7 @@ export default function RootLayout({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</head>
|
</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}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,166 +1,333 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
||||||
|
|
||||||
|
type LoginStage = "login" | "token" | "password" | "success";
|
||||||
|
|
||||||
|
async function postJson(path: string, body?: object) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body ?? {}),
|
||||||
|
});
|
||||||
|
const data: { error?: string } = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.error || "Request failed");
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
const [stage, setStage] = useState<LoginStage>("login");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [token, setToken] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [isSetup, setIsSetup] = useState<boolean | null>(null);
|
const [setupRequired, setSetupRequired] = useState<boolean | null>(null);
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/auth/setup-status")
|
fetch("/api/auth/setup-status")
|
||||||
.then((res) => res.json())
|
.then((response) => response.json())
|
||||||
.then((data) => setIsSetup(data.setupRequired))
|
.then((data) => setSetupRequired(data.setupRequired))
|
||||||
.catch(() => setIsSetup(false)); // fallback
|
.catch(() => setSetupRequired(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function run(action: () => Promise<void>) {
|
||||||
e.preventDefault();
|
|
||||||
setError("");
|
setError("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/auth/login", {
|
await action();
|
||||||
method: "POST",
|
} catch (caught) {
|
||||||
headers: { "Content-Type": "application/json" },
|
setError(caught instanceof Error ? caught.message : "Network error. Please try again.");
|
||||||
body: JSON.stringify({ password }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
setError(data.error || "Login failed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.location.href = "/";
|
|
||||||
} catch {
|
|
||||||
setError("Network error. Please try again.");
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center p-4">
|
<div className="flex min-h-screen items-center justify-center p-4">
|
||||||
<div className="animate-pulse flex items-center justify-center">
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const title =
|
||||||
|
stage === "token"
|
||||||
|
? "Enter reset token"
|
||||||
|
: stage === "password"
|
||||||
|
? "Choose a new password"
|
||||||
|
: stage === "success"
|
||||||
|
? "Password updated"
|
||||||
|
: setupRequired
|
||||||
|
? "Welcome to Study Desk"
|
||||||
|
: "Welcome back";
|
||||||
|
|
||||||
|
const description =
|
||||||
|
stage === "token"
|
||||||
|
? "Find the token in the Study Desk Docker logs. It expires after 15 minutes."
|
||||||
|
: stage === "password"
|
||||||
|
? "Use at least eight characters and enter the password twice."
|
||||||
|
: stage === "success"
|
||||||
|
? "Your old password and existing sessions are no longer valid."
|
||||||
|
: setupRequired
|
||||||
|
? "Please set your admin password for the first time."
|
||||||
|
: "Open your notes, decks, and practice quizzes.";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center p-4">
|
<div className="relative flex min-h-screen items-center justify-center overflow-hidden p-4 sm:p-8">
|
||||||
<div className="w-full max-w-sm animate-fade-in">
|
<div className="absolute inset-0 paper-grid opacity-70" />
|
||||||
{/* Logo / Title area */}
|
<div className="absolute -left-24 top-1/4 h-80 w-80 rounded-full bg-primary/15 blur-3xl" />
|
||||||
<div className="text-center mb-8">
|
<div className="absolute -right-24 bottom-0 h-72 w-72 rounded-full bg-accent/15 blur-3xl" />
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary/10 mb-4">
|
<div className="absolute right-4 top-4 z-10"><ThemeToggle /></div>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Login card */}
|
<main className="relative w-full max-w-md animate-fade-in">
|
||||||
<div className="bg-bg-surface rounded-xl shadow-[var(--shadow-card)] border border-border-light p-6">
|
<header className="mb-7 text-center">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<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>
|
||||||
<div>
|
<p className="text-xs font-bold uppercase tracking-[0.2em] text-primary">Personal workspace</p>
|
||||||
<label
|
<h1 className="editorial-title mt-2 text-4xl text-text-heading">{title}</h1>
|
||||||
htmlFor="password"
|
<p className="mt-1 text-sm text-text-muted">{description}</p>
|
||||||
className="block text-sm font-medium text-text-secondary mb-1.5"
|
</header>
|
||||||
>
|
|
||||||
Password
|
<div className="rounded-[2rem] border border-border-light bg-bg-surface/90 p-6 shadow-[var(--shadow-modal)] backdrop-blur sm:p-8">
|
||||||
</label>
|
{stage === "login" && (
|
||||||
<input
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
|
<PasswordField
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
label="Password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={setPassword}
|
||||||
placeholder={isSetup ? "Create a new password" : "Enter your password"}
|
placeholder={setupRequired ? "Create a new password" : "Enter your password"}
|
||||||
autoFocus
|
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>
|
<ErrorMessage message={error} />
|
||||||
|
<PrimaryButton loading={loading} disabled={password.length < 8}>
|
||||||
{error && (
|
{setupRequired ? "Save Password & Login" : "Sign In"}
|
||||||
<div className="flex items-center gap-2 text-sm text-error bg-error-bg rounded-lg px-3 py-2.5">
|
</PrimaryButton>
|
||||||
<svg
|
{!setupRequired && (
|
||||||
className="w-4 h-4 flex-shrink-0"
|
<button
|
||||||
fill="none"
|
type="button"
|
||||||
stroke="currentColor"
|
onClick={beginReset}
|
||||||
viewBox="0 0 24 24"
|
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
|
Forgot password?
|
||||||
strokeLinecap="round"
|
</button>
|
||||||
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"
|
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</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 Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
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 pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|
@ -18,7 +19,7 @@ export function SharedGroupViewer({ data }: { data: any }) {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
const newProgress: Record<string, { currentIndex: number, total: number }> = {};
|
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 key = data.type === "DECK" ? `flashcard_progress_${item.id}` : `quiz_progress_${item.id}`;
|
||||||
const saved = localStorage.getItem(key);
|
const saved = localStorage.getItem(key);
|
||||||
if (saved) {
|
if (saved) {
|
||||||
|
|
@ -71,7 +72,7 @@ export function SharedGroupViewer({ data }: { data: any }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{items.map((item: any) => (
|
{items.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
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"
|
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 { FlashcardViewer } from "@/components/flashcards/FlashcardViewer";
|
||||||
import { QuizViewer } from "@/components/quizzes/QuizViewer";
|
import { QuizViewer } from "@/components/quizzes/QuizViewer";
|
||||||
import { CardList } from "@/components/flashcards/CardList";
|
import { CardList } from "@/components/flashcards/CardList";
|
||||||
|
import type { SharedStudyItem } from "@/types/study";
|
||||||
|
|
||||||
interface SharedViewerProps {
|
interface SharedViewerProps {
|
||||||
type: string;
|
type: "flashcards" | "quizzes";
|
||||||
data: any;
|
data: SharedStudyItem;
|
||||||
groupMode?: boolean;
|
groupMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,7 +37,7 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp
|
||||||
|
|
||||||
const [showTopics, setShowTopics] = useState(false);
|
const [showTopics, setShowTopics] = useState(false);
|
||||||
const categories = type === "quizzes" && data.questions
|
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 (
|
return (
|
||||||
|
|
@ -137,18 +138,20 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp
|
||||||
view === "study" ? (
|
view === "study" ? (
|
||||||
<FlashcardViewer
|
<FlashcardViewer
|
||||||
key={restartKey}
|
key={restartKey}
|
||||||
cards={data.cards}
|
cards={data.cards ?? []}
|
||||||
deckId={data.id}
|
deckId={data.id}
|
||||||
isShared={true}
|
isShared={true}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CardList cards={data.cards} />
|
<CardList cards={data.cards ?? []} />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<QuizViewer
|
<QuizViewer
|
||||||
key={restartKey}
|
key={restartKey}
|
||||||
quiz={{
|
quiz={{
|
||||||
...data,
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
questions: data.questions ?? [],
|
||||||
progress: [], // Start fresh since it's shared
|
progress: [], // Start fresh since it's shared
|
||||||
}}
|
}}
|
||||||
retakeIds={null}
|
retakeIds={null}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { getShareLink } from "@/services/shareService";
|
import { getShareLink } from "@/services/shareService";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { SharedViewer } from "./SharedViewer";
|
import { SharedViewer } from "./SharedViewer";
|
||||||
import { SharedGroupViewer } from "./SharedGroupViewer";
|
import { SharedGroupViewer } from "./SharedGroupViewer";
|
||||||
|
import type { SharedGroupData, SharedStudyItem } from "@/types/study";
|
||||||
|
|
||||||
|
type SharedContentType = "flashcards" | "quizzes";
|
||||||
|
|
||||||
interface SharedPageProps {
|
interface SharedPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
|
|
@ -27,6 +30,16 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
||||||
notFound();
|
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
|
// Validate that the link matches the URL structure
|
||||||
if (type === "flashcards" && !link.deck) notFound();
|
if (type === "flashcards" && !link.deck) notFound();
|
||||||
if (type === "quizzes" && !link.quizSet) notFound();
|
if (type === "quizzes" && !link.quizSet) notFound();
|
||||||
|
|
@ -38,17 +51,17 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetData: any = null;
|
let targetData: SharedStudyItem | SharedGroupData | null = null;
|
||||||
let targetType = type;
|
let targetType: SharedContentType = type === "flashcards" ? "flashcards" : "quizzes";
|
||||||
|
|
||||||
if (type === "groups" && link.group) {
|
if (type === "groups" && link.group) {
|
||||||
if (itemId) {
|
if (itemId) {
|
||||||
// Find the specific item inside the group
|
// Find the specific item inside the group
|
||||||
if (link.group.type === "DECK") {
|
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";
|
targetType = "flashcards";
|
||||||
} else {
|
} else {
|
||||||
targetData = link.group.quizSets.find(q => q.id === itemId);
|
targetData = link.group.quizSets.find(q => q.id === itemId) ?? null;
|
||||||
targetType = "quizzes";
|
targetType = "quizzes";
|
||||||
}
|
}
|
||||||
if (!targetData) notFound();
|
if (!targetData) notFound();
|
||||||
|
|
@ -59,16 +72,19 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
||||||
targetData = type === "flashcards" ? link.deck : link.quizSet;
|
targetData = type === "flashcards" ? link.deck : link.quizSet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!targetData) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-bg-base flex flex-col">
|
<div className="min-h-screen bg-bg-base flex flex-col">
|
||||||
{/* Read-only Header */}
|
{/* 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="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 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">
|
<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" />
|
<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>
|
</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")}
|
Shared {type === "groups" ? "Group" : (type === "flashcards" ? "Deck" : "Quiz")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -92,13 +108,13 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1 overflow-y-auto">
|
<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 ? (
|
{type === "groups" && !itemId ? (
|
||||||
<SharedGroupViewer data={targetData} />
|
<SharedGroupViewer data={targetData as SharedGroupData} />
|
||||||
) : (
|
) : (
|
||||||
<SharedViewer
|
<SharedViewer
|
||||||
type={targetType}
|
type={targetType}
|
||||||
data={targetData}
|
data={targetData as SharedStudyItem}
|
||||||
groupMode={type === "groups"}
|
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>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAdd(true)}
|
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">
|
<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" />
|
<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) => (
|
{cards.map((card, index) => (
|
||||||
<div
|
<div
|
||||||
key={card.id}
|
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 ? (
|
{editingId === card.id ? (
|
||||||
<div className="p-4 space-y-3">
|
<div className="p-4 space-y-3">
|
||||||
|
|
@ -136,7 +136,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-stretch">
|
<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>
|
<div>
|
||||||
<span className="block text-xs font-medium text-text-muted uppercase tracking-wider mb-1">
|
<span className="block text-xs font-medium text-text-muted uppercase tracking-wider mb-1">
|
||||||
Front
|
Front
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { recordStudyActivity } from "@/lib/activityClient";
|
||||||
|
|
||||||
interface Card {
|
interface Card {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -55,6 +56,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
);
|
);
|
||||||
const [swipeClass, setSwipeClass] = useState("");
|
const [swipeClass, setSwipeClass] = useState("");
|
||||||
const cardRef = useRef<HTMLDivElement>(null);
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
const gradingRef = useRef(false);
|
||||||
|
|
||||||
// Touch/drag state
|
// Touch/drag state
|
||||||
const dragRef = useRef({ startX: 0, currentX: 0, isDragging: false });
|
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
|
// Grade the current card
|
||||||
const gradeCard = useCallback(
|
const gradeCard = useCallback(
|
||||||
(grade: CardResult) => {
|
(grade: CardResult) => {
|
||||||
if (!currentCard || !hasFlippedOnce) return;
|
if (!currentCard || !hasFlippedOnce || gradingRef.current) return;
|
||||||
|
gradingRef.current = true;
|
||||||
|
|
||||||
|
if (!isShared) {
|
||||||
|
recordStudyActivity("FLASHCARD").catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
setToastMessage(null);
|
setToastMessage(null);
|
||||||
const newResults = { ...results, [currentCard.id]: grade };
|
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");
|
setSwipeClass(grade === "correct" ? "animate-swipe-right" : "animate-swipe-left");
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
gradingRef.current = false;
|
||||||
setSwipeClass("");
|
setSwipeClass("");
|
||||||
setIsFlipped(false);
|
setIsFlipped(false);
|
||||||
setHasFlippedOnce(false);
|
setHasFlippedOnce(false);
|
||||||
|
|
@ -144,7 +152,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
}
|
}
|
||||||
}, 350);
|
}, 350);
|
||||||
},
|
},
|
||||||
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled]
|
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled, isShared]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
|
|
@ -236,6 +244,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
|
|
||||||
// Restart handlers
|
// Restart handlers
|
||||||
function restartFullSet() {
|
function restartFullSet() {
|
||||||
|
gradingRef.current = false;
|
||||||
let newOrder: string[];
|
let newOrder: string[];
|
||||||
if (isShuffled) {
|
if (isShuffled) {
|
||||||
newOrder = [...cards.map((c) => c.id)].sort(() => Math.random() - 0.5);
|
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) {
|
if (completed) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16">
|
<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">
|
<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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</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="flex justify-center gap-6 mb-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|
@ -334,7 +343,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
{/* Running tally */}
|
{/* 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 justify-between text-sm">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-success font-medium">{correctCount} ✓</span>
|
<span className="text-success font-medium">{correctCount} ✓</span>
|
||||||
|
|
@ -371,16 +380,16 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
key={currentCard.id}
|
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" : ""
|
isFlipped ? "rotate-x-180" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Front */}
|
{/* 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">
|
<span className="absolute top-3 right-4 text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||||
Front
|
Front
|
||||||
</span>
|
</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]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
{currentCard.front}
|
{currentCard.front}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|
@ -391,7 +400,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Back */}
|
{/* 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">
|
<span className="absolute top-3 right-4 text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||||
Back
|
Back
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -437,12 +446,12 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
<div className="w-full mt-6">
|
<div className="w-full mt-6">
|
||||||
|
|
||||||
{/* Primary Action Row (Grading) */}
|
{/* 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 && (
|
{hasFlippedOnce && (
|
||||||
<div className="flex items-center gap-2 md:gap-4 animate-fade-in pointer-events-auto">
|
<div className="flex items-center gap-2 md:gap-4 animate-fade-in pointer-events-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => gradeCard("missed")}
|
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">
|
<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" />
|
<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>
|
||||||
<button
|
<button
|
||||||
onClick={() => gradeCard("correct")}
|
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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
|
|
||||||
|
|
@ -74,8 +74,8 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
onCreated();
|
onCreated();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message);
|
setError(err instanceof Error ? err.message : "Failed to create deck");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,18 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
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 [instructions, setInstructions] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveResult, setSaveResult] = useState<"success" | "error" | null>(null);
|
const [saveResult, setSaveResult] = useState<"success" | "error" | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/settings/llm-instructions?type=${importType}`)
|
fetch(`/api/settings/llm-instructions?type=${importType}`)
|
||||||
|
|
@ -52,7 +58,7 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCopy() {
|
async function handleCopy() {
|
||||||
await navigator.clipboard.writeText(instructions);
|
await navigator.clipboard.writeText(displayedInstructions);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
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.
|
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>
|
</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
|
<textarea
|
||||||
value={instructions}
|
value={displayedInstructions}
|
||||||
onChange={(e) => setInstructions(e.target.value)}
|
onChange={(event) => setInstructions(batchOverride && event.target.value.startsWith(batchOverride) ? event.target.value.slice(batchOverride.length) : event.target.value)}
|
||||||
rows={14}
|
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"
|
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");
|
const [activeTab, setActiveTab] = useState<"generate" | "import" | "create">("import");
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/40 backdrop-blur-sm animate-fade-in"
|
className="absolute inset-0 bg-black/40 backdrop-blur-sm animate-fade-in"
|
||||||
|
|
@ -29,14 +29,15 @@ export function ImportModal({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal */}
|
{/* 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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 pt-5 pb-0">
|
<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"}
|
Import {importType === "flashcards" ? "Flashcard Deck" : "Quiz"}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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"
|
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">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,16 @@
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { QuizResults } from "./QuizResults";
|
import { QuizResults } from "./QuizResults";
|
||||||
|
import type { QuizAttempt, QuizSummary } from "@/types/study";
|
||||||
|
|
||||||
interface AttemptHistoryProps {
|
interface AttemptHistoryProps {
|
||||||
quiz: any;
|
quiz: QuizSummary;
|
||||||
attempts: any[];
|
attempts: QuizAttempt[];
|
||||||
onRetake: (missedIds: string[]) => void;
|
onRetake: (missedIds: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AttemptHistory({ quiz, attempts, onRetake }: AttemptHistoryProps) {
|
export function AttemptHistory({ quiz, attempts, onRetake }: AttemptHistoryProps) {
|
||||||
const [selectedAttempt, setSelectedAttempt] = useState<any | null>(null);
|
const [selectedAttempt, setSelectedAttempt] = useState<QuizAttempt | null>(null);
|
||||||
|
|
||||||
if (selectedAttempt) {
|
if (selectedAttempt) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,20 @@
|
||||||
|
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { scoreQuestion } from "@/lib/scoring";
|
import { scoreQuestion } from "@/lib/scoring";
|
||||||
|
import type { QuizSummary } from "@/types/study";
|
||||||
|
|
||||||
interface CategoryBreakdownProps {
|
interface CategoryBreakdownProps {
|
||||||
quiz: any;
|
quiz: QuizSummary;
|
||||||
attempt: any;
|
|
||||||
answeredIds: string[];
|
answeredIds: string[];
|
||||||
answers: Record<string, string[]>;
|
answers: Record<string, string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CategoryBreakdown({ quiz, attempt, answeredIds, answers }: CategoryBreakdownProps) {
|
export function CategoryBreakdown({ quiz, answeredIds, answers }: CategoryBreakdownProps) {
|
||||||
// Aggregate stats per category
|
// Aggregate stats per category
|
||||||
const stats: Record<string, { earned: number; possible: number }> = {};
|
const stats: Record<string, { earned: number; possible: number }> = {};
|
||||||
|
|
||||||
answeredIds.forEach((qId) => {
|
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;
|
if (!q) return;
|
||||||
|
|
||||||
const cat = q.category.trim().toLowerCase();
|
const cat = q.category.trim().toLowerCase();
|
||||||
|
|
@ -23,7 +23,11 @@ export function CategoryBreakdown({ quiz, attempt, answeredIds, answers }: Categ
|
||||||
stats[cat] = { earned: 0, possible: 0 };
|
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] || []);
|
const points = scoreQuestion(formattedQ, answers[q.id] || []);
|
||||||
|
|
||||||
stats[cat].earned += points;
|
stats[cat].earned += points;
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,21 @@ import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { CategoryBreakdown } from "./CategoryBreakdown";
|
import { CategoryBreakdown } from "./CategoryBreakdown";
|
||||||
import { scoreQuestion } from "@/lib/scoring";
|
import { scoreQuestion } from "@/lib/scoring";
|
||||||
|
import type { QuizAttempt, QuizQuestion, QuizSummary } from "@/types/study";
|
||||||
|
|
||||||
interface QuizResultsProps {
|
interface QuizResultsProps {
|
||||||
quiz: any; // QuizData
|
quiz: QuizSummary;
|
||||||
attempt: any; // QuizAttempt
|
attempt: QuizAttempt;
|
||||||
onRetake: (missedIds: string[]) => void;
|
onRetake: (missedIds: string[]) => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ReviewItem {
|
||||||
|
question: QuizQuestion;
|
||||||
|
score: number;
|
||||||
|
selections: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsProps) {
|
export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsProps) {
|
||||||
let answers: Record<string, string[]> = {};
|
let answers: Record<string, string[]> = {};
|
||||||
try {
|
try {
|
||||||
|
|
@ -19,16 +26,16 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// Determine non-full-credit questions for review list
|
// Determine non-full-credit questions for review list
|
||||||
const reviewItems: any[] = [];
|
const reviewItems: ReviewItem[] = [];
|
||||||
const missedIds: string[] = [];
|
const missedIds: string[] = [];
|
||||||
|
|
||||||
const answeredIds = Object.keys(answers);
|
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 = {
|
const formattedQ = {
|
||||||
id: q.id,
|
id: q.id,
|
||||||
type: q.type,
|
type: q.type as "MULTIPLE_CHOICE" | "SATA",
|
||||||
options: q.options,
|
options: q.options,
|
||||||
};
|
};
|
||||||
const score = scoreQuestion(formattedQ, answers[q.id] || []);
|
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">
|
<div className="max-w-3xl mx-auto space-y-8 animate-fade-in pb-12">
|
||||||
|
|
||||||
{/* Top Banner */}
|
{/* 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 && (
|
{percentage >= 80 && (
|
||||||
<div className="absolute top-0 left-0 w-full h-2 bg-success"></div>
|
<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>
|
<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 && (
|
{attempt.isPartialRetake && (
|
||||||
<p className="text-sm text-text-secondary mb-4">(Partial Retake)</p>
|
<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>
|
<span className="text-lg text-text-muted mb-1 font-medium">({parseFloat(attempt.score.toFixed(2))} / {attempt.maxScore})</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center gap-4">
|
<div className="flex flex-col justify-center gap-3 sm:flex-row">
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|
@ -92,7 +99,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
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);
|
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"
|
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 */}
|
{/* Category Breakdown */}
|
||||||
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-6 md:p-8">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Review List */}
|
{/* Review List */}
|
||||||
|
|
@ -113,7 +120,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h3 className="text-xl font-bold text-text-heading px-2">Areas for Review</h3>
|
<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 q = item.question;
|
||||||
const sels = item.selections;
|
const sels = item.selections;
|
||||||
return (
|
return (
|
||||||
|
|
@ -128,7 +135,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 mb-6">
|
<div className="space-y-2 mb-6">
|
||||||
{q.options.map((opt: any) => {
|
{q.options.map((opt) => {
|
||||||
const isSelected = sels.includes(opt.id);
|
const isSelected = sels.includes(opt.id);
|
||||||
let stateClass = "cursor-default opacity-80";
|
let stateClass = "cursor-default opacity-80";
|
||||||
let icon = null;
|
let icon = null;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { scoreQuestion } from "@/lib/scoring";
|
import { scoreQuestion } from "@/lib/scoring";
|
||||||
import { QuizResults } from "./QuizResults";
|
import { QuizResults } from "./QuizResults";
|
||||||
|
import { recordStudyActivity } from "@/lib/activityClient";
|
||||||
|
import type { QuizAttempt } from "@/types/study";
|
||||||
|
|
||||||
interface Option {
|
interface Option {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -39,6 +41,7 @@ interface QuizViewerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: QuizViewerProps) {
|
export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: QuizViewerProps) {
|
||||||
|
const submittingQuestionRef = useRef<string | null>(null);
|
||||||
const [order, setOrder] = useState<string[]>([]);
|
const [order, setOrder] = useState<string[]>([]);
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const [answers, setAnswers] = useState<Record<string, string[]>>({});
|
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 [shuffledOptions, setShuffledOptions] = useState<Record<string, Option[]>>({});
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
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);
|
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
// Auto-hide toast
|
// Auto-hide toast
|
||||||
|
|
@ -194,7 +197,11 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
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];
|
const newSubmitted = [...submittedAnswers, currentQId];
|
||||||
setSubmittedAnswers(newSubmitted);
|
setSubmittedAnswers(newSubmitted);
|
||||||
saveProgress(currentIndex, answers);
|
saveProgress(currentIndex, answers);
|
||||||
|
|
@ -202,6 +209,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
|
|
||||||
function handleNext() {
|
function handleNext() {
|
||||||
if (currentIndex + 1 < order.length) {
|
if (currentIndex + 1 < order.length) {
|
||||||
|
submittingQuestionRef.current = null;
|
||||||
const nextIdx = currentIndex + 1;
|
const nextIdx = currentIndex + 1;
|
||||||
setCurrentIndex(nextIdx);
|
setCurrentIndex(nextIdx);
|
||||||
saveProgress(nextIdx, answers);
|
saveProgress(nextIdx, answers);
|
||||||
|
|
@ -290,6 +298,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
setCurrentIndex(0);
|
setCurrentIndex(0);
|
||||||
setAnswers({});
|
setAnswers({});
|
||||||
setSubmittedAnswers([]);
|
setSubmittedAnswers([]);
|
||||||
|
submittingQuestionRef.current = null;
|
||||||
setResultsData(null);
|
setResultsData(null);
|
||||||
|
|
||||||
// Re-shuffle options for the new attempt
|
// Re-shuffle options for the new attempt
|
||||||
|
|
@ -320,10 +329,10 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Unified Card */}
|
{/* 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 */}
|
{/* 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">
|
<div className="text-sm font-mono text-text-secondary whitespace-nowrap tracking-wider">
|
||||||
Q {currentIndex + 1} / {order.length}
|
Q {currentIndex + 1} / {order.length}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -339,20 +348,20 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Question Area */}
|
{/* 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 */}
|
{/* Tags */}
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="mb-8 flex items-start justify-between gap-3">
|
||||||
<span className="inline-flex px-3 py-1 rounded-md bg-primary/10 text-primary text-xs font-bold uppercase tracking-wider">
|
<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}
|
{currentQ.category}
|
||||||
</span>
|
</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"}
|
{currentQ.type === "MULTIPLE_CHOICE" ? "Multiple Choice" : "Select All That Apply"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Prompt */}
|
{/* 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]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
{currentQ.prompt}
|
{currentQ.prompt}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|
@ -374,7 +383,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
} else if (!isSelected && opt.isCorrect) {
|
} else if (!isSelected && opt.isCorrect) {
|
||||||
stateClass = "border-success/50 bg-success/5 text-text-heading";
|
stateClass = "border-success/50 bg-success/5 text-text-heading";
|
||||||
rightIcon = (
|
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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -392,7 +401,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
<div
|
<div
|
||||||
key={opt.id}
|
key={opt.id}
|
||||||
onClick={() => toggleOption(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="flex-shrink-0">
|
||||||
<div className={`w-5 h-5 rounded ${currentQ.type === 'SATA' ? 'border' : 'border rounded-full'} flex items-center justify-center transition-colors ${
|
<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}
|
{opt.text}
|
||||||
</div>
|
</div>
|
||||||
{rightIcon && (
|
{rightIcon && (
|
||||||
<div className="flex-shrink-0 pl-2">
|
<div className="col-start-2 min-w-0 md:flex-shrink-0 md:pl-2">
|
||||||
{rightIcon}
|
{rightIcon}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -478,7 +487,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Action Button */}
|
{/* 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 ? (
|
{!isSubmitted ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
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 Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { ClassTabs } from "./ClassTabs";
|
|
||||||
|
|
||||||
interface ClassHeaderProps {
|
interface ClassHeaderProps {
|
||||||
classSlug: string;
|
|
||||||
className: string;
|
className: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClassHeader({ classSlug, className }: ClassHeaderProps) {
|
export function ClassHeader({ className }: ClassHeaderProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
// If we are deeper than /[classSlug]/[tab], hide this header to save space
|
// If we are deeper than /[classSlug]/[tab], hide this header to save space
|
||||||
|
|
@ -23,21 +21,21 @@ export function ClassHeader({ classSlug, className }: ClassHeaderProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<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 className="mb-6">
|
<div>
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
All Classes
|
Library
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
|
|
||||||
<ClassTabs classSlug={classSlug} />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ export function ClassTabs({ classSlug }: ClassTabsProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border-light mb-6">
|
<div className="mb-8 border-b border-border-light">
|
||||||
<nav className="flex gap-0" aria-label="Study modes">
|
<nav className="flex gap-2" aria-label="Study modes">
|
||||||
{STUDY_MODES.map((mode) => {
|
{STUDY_MODES.map((mode) => {
|
||||||
const href = `/${classSlug}/${mode.path}`;
|
const href = `/${classSlug}/${mode.path}`;
|
||||||
const isActive = pathname === href || pathname.startsWith(href + "/");
|
const isActive = pathname === href || pathname.startsWith(href + "/");
|
||||||
|
|
@ -22,7 +22,7 @@ export function ClassTabs({ classSlug }: ClassTabsProps) {
|
||||||
<Link
|
<Link
|
||||||
key={mode.key}
|
key={mode.key}
|
||||||
href={href}
|
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
|
isActive
|
||||||
? "text-primary"
|
? "text-primary"
|
||||||
: "text-text-muted hover:text-text-heading"
|
: "text-text-muted hover:text-text-heading"
|
||||||
|
|
@ -30,7 +30,7 @@ export function ClassTabs({ classSlug }: ClassTabsProps) {
|
||||||
>
|
>
|
||||||
{mode.label}
|
{mode.label}
|
||||||
{isActive && (
|
{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>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,56 @@
|
||||||
"use client";
|
"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 Link from "next/link";
|
||||||
import { ThemeToggle } from "./ThemeToggle";
|
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() {
|
export function Navbar() {
|
||||||
const router = useRouter();
|
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() {
|
async function handleLogout() {
|
||||||
await fetch("/api/auth/logout", { method: "POST" });
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
|
|
@ -13,40 +58,90 @@ export function Navbar() {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const nav = (
|
||||||
<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">
|
<Link href="/" onClick={() => setOpen(false)} className="mb-9 flex items-center gap-3">
|
||||||
<div className="flex items-center justify-between h-14">
|
<Mark />
|
||||||
<Link
|
<span>
|
||||||
href="/"
|
<span className="block text-[11px] font-bold uppercase tracking-[0.2em] text-text-muted">Your</span>
|
||||||
className="flex items-center gap-2.5 text-text-heading font-semibold hover:text-primary transition-colors"
|
<span className="editorial-title block text-xl leading-none text-text-heading">Study Desk</span>
|
||||||
>
|
</span>
|
||||||
<svg
|
</Link>
|
||||||
className="w-5 h-5 text-primary"
|
|
||||||
fill="none"
|
<nav className="space-y-1" aria-label="Primary navigation">
|
||||||
stroke="currentColor"
|
<Link
|
||||||
viewBox="0 0 24 24"
|
href="/"
|
||||||
>
|
onClick={() => setOpen(false)}
|
||||||
<path
|
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"}`}
|
||||||
strokeLinecap="round"
|
>
|
||||||
strokeLinejoin="round"
|
<span aria-hidden className="text-lg">⌂</span> Library
|
||||||
strokeWidth={2}
|
</Link>
|
||||||
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"
|
{classSlug && (
|
||||||
/>
|
<div className="pt-6">
|
||||||
</svg>
|
<p className="mb-2 px-3 text-[10px] font-bold uppercase tracking-[0.18em] text-text-muted">Current class</p>
|
||||||
Study
|
{STUDY_MODES.map((mode, index) => {
|
||||||
</Link>
|
const isActive = pathname.includes(`/${mode.path}`);
|
||||||
<div className="flex items-center gap-4">
|
return (
|
||||||
<ThemeToggle />
|
<Link
|
||||||
<button
|
key={mode.key}
|
||||||
onClick={handleLogout}
|
href={`/${classSlug}/${mode.path}`}
|
||||||
className="text-sm text-text-muted hover:text-text-heading transition-colors cursor-pointer"
|
onClick={() => setOpen(false)}
|
||||||
>
|
className={`${index > 0 ? "mt-1 " : ""}flex min-h-11 items-center gap-3 rounded-xl px-3 text-sm font-semibold transition-colors ${isActive ? "bg-bg-callout text-primary" : "text-text-secondary hover:bg-bg-surface-alt hover:text-text-heading"}`}
|
||||||
Sign out
|
>
|
||||||
</button>
|
<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>
|
</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>
|
</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>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
interface ShareMenuProps {
|
interface ShareMenuProps {
|
||||||
targetType: "DECK" | "QUIZ" | "GROUP";
|
targetType: "DECK" | "QUIZ" | "GROUP";
|
||||||
contentId: string;
|
contentId: string;
|
||||||
classSlug: 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 [token, setToken] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [isGroupShared, setIsGroupShared] = useState(false);
|
const [isGroupShared, setIsGroupShared] = useState(false);
|
||||||
const [groupName, setGroupName] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
@ -45,8 +64,15 @@ export function ShareMenu({ targetType, contentId, classSlug }: ShareMenuProps)
|
||||||
|
|
||||||
async function handleCopy() {
|
async function handleCopy() {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
const typeStr = targetType === "GROUP" ? "groups" : (targetType === "DECK" ? "flashcards" : "quizzes");
|
const typeStr = isGroupShared
|
||||||
const url = `${window.location.origin}/shared/${classSlug}/${typeStr}/${token}`;
|
? "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);
|
await navigator.clipboard.writeText(url);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
|
@ -55,8 +81,9 @@ export function ShareMenu({ targetType, contentId, classSlug }: ShareMenuProps)
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
onClick={() => setOpen(!open)}
|
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"
|
title="Share"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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 && (
|
{open && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
<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">
|
<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="text-sm font-semibold text-text-heading mb-3">Public Sharing</h4>
|
<h4 className="editorial-title mb-3 text-xl text-text-heading">Public sharing</h4>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="h-8 bg-bg-surface-alt rounded w-full animate-subtle-pulse" />
|
<div className="h-8 bg-bg-surface-alt rounded w-full animate-subtle-pulse" />
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export function ThemeToggle() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!mounted) {
|
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() {
|
function toggleTheme() {
|
||||||
|
|
@ -30,9 +30,9 @@ export function ThemeToggle() {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
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"}
|
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 ? (
|
{isDark ? (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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 = [
|
export const STUDY_MODES = [
|
||||||
{ key: "flashcards", label: "Flashcards", path: "flashcards" },
|
{ key: "flashcards", label: "Flashcards", path: "flashcards", icon: "▤" },
|
||||||
{ key: "quizzes", label: "Quizzes", path: "quizzes" },
|
{ 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.
|
// Future modes get added here — the layout, tab bar, and auth middleware need no changes.
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,38 @@ export type AuthSecurity = Prisma.AuthSecurityModel
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type Setting = Prisma.SettingModel
|
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
|
* Model MaterialGroup
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type MaterialGroup = Prisma.MaterialGroupModel
|
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
|
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
|
* Model MaterialGroup
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type MaterialGroup = Prisma.MaterialGroupModel
|
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',
|
ShareLink: 'ShareLink',
|
||||||
AuthSecurity: 'AuthSecurity',
|
AuthSecurity: 'AuthSecurity',
|
||||||
Setting: 'Setting',
|
Setting: 'Setting',
|
||||||
MaterialGroup: 'MaterialGroup'
|
StudyActivity: 'StudyActivity',
|
||||||
|
ArcadePack: 'ArcadePack',
|
||||||
|
ArcadeAttempt: 'ArcadeAttempt',
|
||||||
|
MaterialGroup: 'MaterialGroup',
|
||||||
|
SpacedRepetitionSet: 'SpacedRepetitionSet',
|
||||||
|
SpacedRepetitionSetDeck: 'SpacedRepetitionSetDeck',
|
||||||
|
SpacedRepetitionCardState: 'SpacedRepetitionCardState'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||||
|
|
@ -411,7 +417,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||||
omit: GlobalOmitOptions
|
omit: GlobalOmitOptions
|
||||||
}
|
}
|
||||||
meta: {
|
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
|
txIsolationLevel: TransactionIsolationLevel
|
||||||
}
|
}
|
||||||
model: {
|
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: {
|
MaterialGroup: {
|
||||||
payload: Prisma.$MaterialGroupPayload<ExtArgs>
|
payload: Prisma.$MaterialGroupPayload<ExtArgs>
|
||||||
fields: Prisma.MaterialGroupFieldRefs
|
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: {
|
other: {
|
||||||
|
|
@ -1470,6 +1920,52 @@ export const SettingScalarFieldEnum = {
|
||||||
export type SettingScalarFieldEnum = (typeof SettingScalarFieldEnum)[keyof typeof 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 = {
|
export const MaterialGroupScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
classId: 'classId',
|
classId: 'classId',
|
||||||
|
|
@ -1482,6 +1978,52 @@ export const MaterialGroupScalarFieldEnum = {
|
||||||
export type MaterialGroupScalarFieldEnum = (typeof MaterialGroupScalarFieldEnum)[keyof typeof 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 = {
|
export const SortOrder = {
|
||||||
asc: 'asc',
|
asc: 'asc',
|
||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
|
|
@ -1659,7 +2201,13 @@ export type GlobalOmitConfig = {
|
||||||
shareLink?: Prisma.ShareLinkOmit
|
shareLink?: Prisma.ShareLinkOmit
|
||||||
authSecurity?: Prisma.AuthSecurityOmit
|
authSecurity?: Prisma.AuthSecurityOmit
|
||||||
setting?: Prisma.SettingOmit
|
setting?: Prisma.SettingOmit
|
||||||
|
studyActivity?: Prisma.StudyActivityOmit
|
||||||
|
arcadePack?: Prisma.ArcadePackOmit
|
||||||
|
arcadeAttempt?: Prisma.ArcadeAttemptOmit
|
||||||
materialGroup?: Prisma.MaterialGroupOmit
|
materialGroup?: Prisma.MaterialGroupOmit
|
||||||
|
spacedRepetitionSet?: Prisma.SpacedRepetitionSetOmit
|
||||||
|
spacedRepetitionSetDeck?: Prisma.SpacedRepetitionSetDeckOmit
|
||||||
|
spacedRepetitionCardState?: Prisma.SpacedRepetitionCardStateOmit
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Types for Logging */
|
/* Types for Logging */
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,13 @@ export const ModelName = {
|
||||||
ShareLink: 'ShareLink',
|
ShareLink: 'ShareLink',
|
||||||
AuthSecurity: 'AuthSecurity',
|
AuthSecurity: 'AuthSecurity',
|
||||||
Setting: 'Setting',
|
Setting: 'Setting',
|
||||||
MaterialGroup: 'MaterialGroup'
|
StudyActivity: 'StudyActivity',
|
||||||
|
ArcadePack: 'ArcadePack',
|
||||||
|
ArcadeAttempt: 'ArcadeAttempt',
|
||||||
|
MaterialGroup: 'MaterialGroup',
|
||||||
|
SpacedRepetitionSet: 'SpacedRepetitionSet',
|
||||||
|
SpacedRepetitionSetDeck: 'SpacedRepetitionSetDeck',
|
||||||
|
SpacedRepetitionCardState: 'SpacedRepetitionCardState'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||||
|
|
@ -209,6 +215,52 @@ export const SettingScalarFieldEnum = {
|
||||||
export type SettingScalarFieldEnum = (typeof SettingScalarFieldEnum)[keyof typeof 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 = {
|
export const MaterialGroupScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
classId: 'classId',
|
classId: 'classId',
|
||||||
|
|
@ -221,6 +273,52 @@ export const MaterialGroupScalarFieldEnum = {
|
||||||
export type MaterialGroupScalarFieldEnum = (typeof MaterialGroupScalarFieldEnum)[keyof typeof 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 = {
|
export const SortOrder = {
|
||||||
asc: 'asc',
|
asc: 'asc',
|
||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
|
|
|
||||||
|
|
@ -19,5 +19,11 @@ export type * from './models/QuizAttempt'
|
||||||
export type * from './models/ShareLink'
|
export type * from './models/ShareLink'
|
||||||
export type * from './models/AuthSecurity'
|
export type * from './models/AuthSecurity'
|
||||||
export type * from './models/Setting'
|
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/MaterialGroup'
|
||||||
|
export type * from './models/SpacedRepetitionSet'
|
||||||
|
export type * from './models/SpacedRepetitionSetDeck'
|
||||||
|
export type * from './models/SpacedRepetitionCardState'
|
||||||
export type * from './commonInputTypes'
|
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
|
decks?: Prisma.DeckListRelationFilter
|
||||||
quizSets?: Prisma.QuizSetListRelationFilter
|
quizSets?: Prisma.QuizSetListRelationFilter
|
||||||
materialGroups?: Prisma.MaterialGroupListRelationFilter
|
materialGroups?: Prisma.MaterialGroupListRelationFilter
|
||||||
|
spacedRepetitionSets?: Prisma.SpacedRepetitionSetListRelationFilter
|
||||||
|
arcadePacks?: Prisma.ArcadePackListRelationFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassOrderByWithRelationInput = {
|
export type ClassOrderByWithRelationInput = {
|
||||||
|
|
@ -230,6 +232,8 @@ export type ClassOrderByWithRelationInput = {
|
||||||
decks?: Prisma.DeckOrderByRelationAggregateInput
|
decks?: Prisma.DeckOrderByRelationAggregateInput
|
||||||
quizSets?: Prisma.QuizSetOrderByRelationAggregateInput
|
quizSets?: Prisma.QuizSetOrderByRelationAggregateInput
|
||||||
materialGroups?: Prisma.MaterialGroupOrderByRelationAggregateInput
|
materialGroups?: Prisma.MaterialGroupOrderByRelationAggregateInput
|
||||||
|
spacedRepetitionSets?: Prisma.SpacedRepetitionSetOrderByRelationAggregateInput
|
||||||
|
arcadePacks?: Prisma.ArcadePackOrderByRelationAggregateInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassWhereUniqueInput = Prisma.AtLeast<{
|
export type ClassWhereUniqueInput = Prisma.AtLeast<{
|
||||||
|
|
@ -244,6 +248,8 @@ export type ClassWhereUniqueInput = Prisma.AtLeast<{
|
||||||
decks?: Prisma.DeckListRelationFilter
|
decks?: Prisma.DeckListRelationFilter
|
||||||
quizSets?: Prisma.QuizSetListRelationFilter
|
quizSets?: Prisma.QuizSetListRelationFilter
|
||||||
materialGroups?: Prisma.MaterialGroupListRelationFilter
|
materialGroups?: Prisma.MaterialGroupListRelationFilter
|
||||||
|
spacedRepetitionSets?: Prisma.SpacedRepetitionSetListRelationFilter
|
||||||
|
arcadePacks?: Prisma.ArcadePackListRelationFilter
|
||||||
}, "id" | "slug">
|
}, "id" | "slug">
|
||||||
|
|
||||||
export type ClassOrderByWithAggregationInput = {
|
export type ClassOrderByWithAggregationInput = {
|
||||||
|
|
@ -279,6 +285,8 @@ export type ClassCreateInput = {
|
||||||
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
|
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
|
||||||
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
|
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
|
||||||
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
|
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
|
||||||
|
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
|
||||||
|
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassUncheckedCreateInput = {
|
export type ClassUncheckedCreateInput = {
|
||||||
|
|
@ -290,6 +298,8 @@ export type ClassUncheckedCreateInput = {
|
||||||
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
|
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
|
||||||
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
|
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
|
||||||
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
|
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
|
||||||
|
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
|
||||||
|
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassUpdateInput = {
|
export type ClassUpdateInput = {
|
||||||
|
|
@ -301,6 +311,8 @@ export type ClassUpdateInput = {
|
||||||
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
|
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
|
||||||
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
|
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
|
||||||
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
|
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
|
||||||
|
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
|
||||||
|
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassUncheckedUpdateInput = {
|
export type ClassUncheckedUpdateInput = {
|
||||||
|
|
@ -312,6 +324,8 @@ export type ClassUncheckedUpdateInput = {
|
||||||
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
|
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
|
||||||
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
|
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
|
||||||
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
|
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
|
||||||
|
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
|
||||||
|
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassCreateManyInput = {
|
export type ClassCreateManyInput = {
|
||||||
|
|
@ -419,6 +433,20 @@ export type ClassUpdateOneRequiredWithoutQuizSetsNestedInput = {
|
||||||
update?: Prisma.XOR<Prisma.XOR<Prisma.ClassUpdateToOneWithWhereWithoutQuizSetsInput, Prisma.ClassUpdateWithoutQuizSetsInput>, Prisma.ClassUncheckedUpdateWithoutQuizSetsInput>
|
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 = {
|
export type ClassCreateNestedOneWithoutMaterialGroupsInput = {
|
||||||
create?: Prisma.XOR<Prisma.ClassCreateWithoutMaterialGroupsInput, Prisma.ClassUncheckedCreateWithoutMaterialGroupsInput>
|
create?: Prisma.XOR<Prisma.ClassCreateWithoutMaterialGroupsInput, Prisma.ClassUncheckedCreateWithoutMaterialGroupsInput>
|
||||||
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutMaterialGroupsInput
|
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutMaterialGroupsInput
|
||||||
|
|
@ -433,6 +461,20 @@ export type ClassUpdateOneRequiredWithoutMaterialGroupsNestedInput = {
|
||||||
update?: Prisma.XOR<Prisma.XOR<Prisma.ClassUpdateToOneWithWhereWithoutMaterialGroupsInput, Prisma.ClassUpdateWithoutMaterialGroupsInput>, Prisma.ClassUncheckedUpdateWithoutMaterialGroupsInput>
|
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 = {
|
export type ClassCreateWithoutDecksInput = {
|
||||||
id?: string
|
id?: string
|
||||||
slug: string
|
slug: string
|
||||||
|
|
@ -441,6 +483,8 @@ export type ClassCreateWithoutDecksInput = {
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
|
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
|
||||||
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
|
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
|
||||||
|
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
|
||||||
|
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassUncheckedCreateWithoutDecksInput = {
|
export type ClassUncheckedCreateWithoutDecksInput = {
|
||||||
|
|
@ -451,6 +495,8 @@ export type ClassUncheckedCreateWithoutDecksInput = {
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
|
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
|
||||||
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
|
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
|
||||||
|
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
|
||||||
|
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassCreateOrConnectWithoutDecksInput = {
|
export type ClassCreateOrConnectWithoutDecksInput = {
|
||||||
|
|
@ -477,6 +523,8 @@ export type ClassUpdateWithoutDecksInput = {
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
|
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
|
||||||
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
|
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
|
||||||
|
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
|
||||||
|
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassUncheckedUpdateWithoutDecksInput = {
|
export type ClassUncheckedUpdateWithoutDecksInput = {
|
||||||
|
|
@ -487,6 +535,8 @@ export type ClassUncheckedUpdateWithoutDecksInput = {
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
|
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
|
||||||
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
|
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
|
||||||
|
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
|
||||||
|
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassCreateWithoutQuizSetsInput = {
|
export type ClassCreateWithoutQuizSetsInput = {
|
||||||
|
|
@ -497,6 +547,8 @@ export type ClassCreateWithoutQuizSetsInput = {
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
|
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
|
||||||
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
|
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
|
||||||
|
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
|
||||||
|
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassUncheckedCreateWithoutQuizSetsInput = {
|
export type ClassUncheckedCreateWithoutQuizSetsInput = {
|
||||||
|
|
@ -507,6 +559,8 @@ export type ClassUncheckedCreateWithoutQuizSetsInput = {
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
|
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
|
||||||
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
|
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
|
||||||
|
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
|
||||||
|
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassCreateOrConnectWithoutQuizSetsInput = {
|
export type ClassCreateOrConnectWithoutQuizSetsInput = {
|
||||||
|
|
@ -533,6 +587,8 @@ export type ClassUpdateWithoutQuizSetsInput = {
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
|
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
|
||||||
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
|
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
|
||||||
|
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
|
||||||
|
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassUncheckedUpdateWithoutQuizSetsInput = {
|
export type ClassUncheckedUpdateWithoutQuizSetsInput = {
|
||||||
|
|
@ -543,6 +599,72 @@ export type ClassUncheckedUpdateWithoutQuizSetsInput = {
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
|
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
|
||||||
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
|
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 = {
|
export type ClassCreateWithoutMaterialGroupsInput = {
|
||||||
|
|
@ -553,6 +675,8 @@ export type ClassCreateWithoutMaterialGroupsInput = {
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
|
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
|
||||||
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
|
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
|
||||||
|
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
|
||||||
|
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassUncheckedCreateWithoutMaterialGroupsInput = {
|
export type ClassUncheckedCreateWithoutMaterialGroupsInput = {
|
||||||
|
|
@ -563,6 +687,8 @@ export type ClassUncheckedCreateWithoutMaterialGroupsInput = {
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
|
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
|
||||||
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
|
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
|
||||||
|
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
|
||||||
|
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassCreateOrConnectWithoutMaterialGroupsInput = {
|
export type ClassCreateOrConnectWithoutMaterialGroupsInput = {
|
||||||
|
|
@ -589,6 +715,8 @@ export type ClassUpdateWithoutMaterialGroupsInput = {
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
|
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
|
||||||
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
|
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
|
||||||
|
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
|
||||||
|
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassUncheckedUpdateWithoutMaterialGroupsInput = {
|
export type ClassUncheckedUpdateWithoutMaterialGroupsInput = {
|
||||||
|
|
@ -599,6 +727,72 @@ export type ClassUncheckedUpdateWithoutMaterialGroupsInput = {
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
|
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
|
||||||
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
|
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
|
decks: number
|
||||||
quizSets: number
|
quizSets: number
|
||||||
materialGroups: number
|
materialGroups: number
|
||||||
|
spacedRepetitionSets: number
|
||||||
|
arcadePacks: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
export type ClassCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
decks?: boolean | ClassCountOutputTypeCountDecksArgs
|
decks?: boolean | ClassCountOutputTypeCountDecksArgs
|
||||||
quizSets?: boolean | ClassCountOutputTypeCountQuizSetsArgs
|
quizSets?: boolean | ClassCountOutputTypeCountQuizSetsArgs
|
||||||
materialGroups?: boolean | ClassCountOutputTypeCountMaterialGroupsArgs
|
materialGroups?: boolean | ClassCountOutputTypeCountMaterialGroupsArgs
|
||||||
|
spacedRepetitionSets?: boolean | ClassCountOutputTypeCountSpacedRepetitionSetsArgs
|
||||||
|
arcadePacks?: boolean | ClassCountOutputTypeCountArcadePacksArgs
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -649,6 +847,20 @@ export type ClassCountOutputTypeCountMaterialGroupsArgs<ExtArgs extends runtime.
|
||||||
where?: Prisma.MaterialGroupWhereInput
|
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<{
|
export type ClassSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||||
id?: boolean
|
id?: boolean
|
||||||
|
|
@ -659,6 +871,8 @@ export type ClassSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
||||||
decks?: boolean | Prisma.Class$decksArgs<ExtArgs>
|
decks?: boolean | Prisma.Class$decksArgs<ExtArgs>
|
||||||
quizSets?: boolean | Prisma.Class$quizSetsArgs<ExtArgs>
|
quizSets?: boolean | Prisma.Class$quizSetsArgs<ExtArgs>
|
||||||
materialGroups?: boolean | Prisma.Class$materialGroupsArgs<ExtArgs>
|
materialGroups?: boolean | Prisma.Class$materialGroupsArgs<ExtArgs>
|
||||||
|
spacedRepetitionSets?: boolean | Prisma.Class$spacedRepetitionSetsArgs<ExtArgs>
|
||||||
|
arcadePacks?: boolean | Prisma.Class$arcadePacksArgs<ExtArgs>
|
||||||
_count?: boolean | Prisma.ClassCountOutputTypeDefaultArgs<ExtArgs>
|
_count?: boolean | Prisma.ClassCountOutputTypeDefaultArgs<ExtArgs>
|
||||||
}, ExtArgs["result"]["class"]>
|
}, ExtArgs["result"]["class"]>
|
||||||
|
|
||||||
|
|
@ -691,6 +905,8 @@ export type ClassInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
||||||
decks?: boolean | Prisma.Class$decksArgs<ExtArgs>
|
decks?: boolean | Prisma.Class$decksArgs<ExtArgs>
|
||||||
quizSets?: boolean | Prisma.Class$quizSetsArgs<ExtArgs>
|
quizSets?: boolean | Prisma.Class$quizSetsArgs<ExtArgs>
|
||||||
materialGroups?: boolean | Prisma.Class$materialGroupsArgs<ExtArgs>
|
materialGroups?: boolean | Prisma.Class$materialGroupsArgs<ExtArgs>
|
||||||
|
spacedRepetitionSets?: boolean | Prisma.Class$spacedRepetitionSetsArgs<ExtArgs>
|
||||||
|
arcadePacks?: boolean | Prisma.Class$arcadePacksArgs<ExtArgs>
|
||||||
_count?: boolean | Prisma.ClassCountOutputTypeDefaultArgs<ExtArgs>
|
_count?: boolean | Prisma.ClassCountOutputTypeDefaultArgs<ExtArgs>
|
||||||
}
|
}
|
||||||
export type ClassIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
|
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>[]
|
decks: Prisma.$DeckPayload<ExtArgs>[]
|
||||||
quizSets: Prisma.$QuizSetPayload<ExtArgs>[]
|
quizSets: Prisma.$QuizSetPayload<ExtArgs>[]
|
||||||
materialGroups: Prisma.$MaterialGroupPayload<ExtArgs>[]
|
materialGroups: Prisma.$MaterialGroupPayload<ExtArgs>[]
|
||||||
|
spacedRepetitionSets: Prisma.$SpacedRepetitionSetPayload<ExtArgs>[]
|
||||||
|
arcadePacks: Prisma.$ArcadePackPayload<ExtArgs>[]
|
||||||
}
|
}
|
||||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||||
id: string
|
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>
|
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>
|
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>
|
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.
|
* Attaches callbacks for the resolution and/or rejection of the Promise.
|
||||||
* @param onfulfilled The callback to execute when the Promise is resolved.
|
* @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[]
|
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
|
* Class without action
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,7 @@ export type DeckWhereInput = {
|
||||||
cards?: Prisma.FlashcardListRelationFilter
|
cards?: Prisma.FlashcardListRelationFilter
|
||||||
shareLink?: Prisma.XOR<Prisma.ShareLinkNullableScalarRelationFilter, Prisma.ShareLinkWhereInput> | null
|
shareLink?: Prisma.XOR<Prisma.ShareLinkNullableScalarRelationFilter, Prisma.ShareLinkWhereInput> | null
|
||||||
progress?: Prisma.StudyProgressListRelationFilter
|
progress?: Prisma.StudyProgressListRelationFilter
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckListRelationFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckOrderByWithRelationInput = {
|
export type DeckOrderByWithRelationInput = {
|
||||||
|
|
@ -252,6 +253,7 @@ export type DeckOrderByWithRelationInput = {
|
||||||
cards?: Prisma.FlashcardOrderByRelationAggregateInput
|
cards?: Prisma.FlashcardOrderByRelationAggregateInput
|
||||||
shareLink?: Prisma.ShareLinkOrderByWithRelationInput
|
shareLink?: Prisma.ShareLinkOrderByWithRelationInput
|
||||||
progress?: Prisma.StudyProgressOrderByRelationAggregateInput
|
progress?: Prisma.StudyProgressOrderByRelationAggregateInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckOrderByRelationAggregateInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckWhereUniqueInput = Prisma.AtLeast<{
|
export type DeckWhereUniqueInput = Prisma.AtLeast<{
|
||||||
|
|
@ -270,6 +272,7 @@ export type DeckWhereUniqueInput = Prisma.AtLeast<{
|
||||||
cards?: Prisma.FlashcardListRelationFilter
|
cards?: Prisma.FlashcardListRelationFilter
|
||||||
shareLink?: Prisma.XOR<Prisma.ShareLinkNullableScalarRelationFilter, Prisma.ShareLinkWhereInput> | null
|
shareLink?: Prisma.XOR<Prisma.ShareLinkNullableScalarRelationFilter, Prisma.ShareLinkWhereInput> | null
|
||||||
progress?: Prisma.StudyProgressListRelationFilter
|
progress?: Prisma.StudyProgressListRelationFilter
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckListRelationFilter
|
||||||
}, "id">
|
}, "id">
|
||||||
|
|
||||||
export type DeckOrderByWithAggregationInput = {
|
export type DeckOrderByWithAggregationInput = {
|
||||||
|
|
@ -311,6 +314,7 @@ export type DeckCreateInput = {
|
||||||
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
|
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
|
||||||
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
|
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
|
||||||
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
|
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckUncheckedCreateInput = {
|
export type DeckUncheckedCreateInput = {
|
||||||
|
|
@ -324,6 +328,7 @@ export type DeckUncheckedCreateInput = {
|
||||||
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
|
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
|
||||||
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
|
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
|
||||||
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
|
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckUpdateInput = {
|
export type DeckUpdateInput = {
|
||||||
|
|
@ -337,6 +342,7 @@ export type DeckUpdateInput = {
|
||||||
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
|
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
|
||||||
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
|
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
|
||||||
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
|
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckUncheckedUpdateInput = {
|
export type DeckUncheckedUpdateInput = {
|
||||||
|
|
@ -350,6 +356,7 @@ export type DeckUncheckedUpdateInput = {
|
||||||
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
|
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
|
||||||
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
|
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
|
||||||
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
|
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckCreateManyInput = {
|
export type DeckCreateManyInput = {
|
||||||
|
|
@ -572,6 +579,20 @@ export type DeckUncheckedUpdateManyWithoutGroupNestedInput = {
|
||||||
deleteMany?: Prisma.DeckScalarWhereInput | Prisma.DeckScalarWhereInput[]
|
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 = {
|
export type DeckCreateWithoutClassInput = {
|
||||||
id?: string
|
id?: string
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -582,6 +603,7 @@ export type DeckCreateWithoutClassInput = {
|
||||||
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
|
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
|
||||||
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
|
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
|
||||||
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
|
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckUncheckedCreateWithoutClassInput = {
|
export type DeckUncheckedCreateWithoutClassInput = {
|
||||||
|
|
@ -594,6 +616,7 @@ export type DeckUncheckedCreateWithoutClassInput = {
|
||||||
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
|
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
|
||||||
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
|
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
|
||||||
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
|
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckCreateOrConnectWithoutClassInput = {
|
export type DeckCreateOrConnectWithoutClassInput = {
|
||||||
|
|
@ -644,6 +667,7 @@ export type DeckCreateWithoutCardsInput = {
|
||||||
group?: Prisma.MaterialGroupCreateNestedOneWithoutDecksInput
|
group?: Prisma.MaterialGroupCreateNestedOneWithoutDecksInput
|
||||||
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
|
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
|
||||||
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
|
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckUncheckedCreateWithoutCardsInput = {
|
export type DeckUncheckedCreateWithoutCardsInput = {
|
||||||
|
|
@ -656,6 +680,7 @@ export type DeckUncheckedCreateWithoutCardsInput = {
|
||||||
groupId?: string | null
|
groupId?: string | null
|
||||||
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
|
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
|
||||||
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
|
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckCreateOrConnectWithoutCardsInput = {
|
export type DeckCreateOrConnectWithoutCardsInput = {
|
||||||
|
|
@ -684,6 +709,7 @@ export type DeckUpdateWithoutCardsInput = {
|
||||||
group?: Prisma.MaterialGroupUpdateOneWithoutDecksNestedInput
|
group?: Prisma.MaterialGroupUpdateOneWithoutDecksNestedInput
|
||||||
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
|
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
|
||||||
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
|
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckUncheckedUpdateWithoutCardsInput = {
|
export type DeckUncheckedUpdateWithoutCardsInput = {
|
||||||
|
|
@ -696,6 +722,7 @@ export type DeckUncheckedUpdateWithoutCardsInput = {
|
||||||
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
|
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
|
||||||
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
|
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckCreateWithoutProgressInput = {
|
export type DeckCreateWithoutProgressInput = {
|
||||||
|
|
@ -708,6 +735,7 @@ export type DeckCreateWithoutProgressInput = {
|
||||||
group?: Prisma.MaterialGroupCreateNestedOneWithoutDecksInput
|
group?: Prisma.MaterialGroupCreateNestedOneWithoutDecksInput
|
||||||
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
|
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
|
||||||
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
|
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckUncheckedCreateWithoutProgressInput = {
|
export type DeckUncheckedCreateWithoutProgressInput = {
|
||||||
|
|
@ -720,6 +748,7 @@ export type DeckUncheckedCreateWithoutProgressInput = {
|
||||||
groupId?: string | null
|
groupId?: string | null
|
||||||
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
|
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
|
||||||
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
|
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckCreateOrConnectWithoutProgressInput = {
|
export type DeckCreateOrConnectWithoutProgressInput = {
|
||||||
|
|
@ -748,6 +777,7 @@ export type DeckUpdateWithoutProgressInput = {
|
||||||
group?: Prisma.MaterialGroupUpdateOneWithoutDecksNestedInput
|
group?: Prisma.MaterialGroupUpdateOneWithoutDecksNestedInput
|
||||||
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
|
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
|
||||||
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
|
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckUncheckedUpdateWithoutProgressInput = {
|
export type DeckUncheckedUpdateWithoutProgressInput = {
|
||||||
|
|
@ -760,6 +790,7 @@ export type DeckUncheckedUpdateWithoutProgressInput = {
|
||||||
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
|
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
|
||||||
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
|
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckCreateWithoutShareLinkInput = {
|
export type DeckCreateWithoutShareLinkInput = {
|
||||||
|
|
@ -772,6 +803,7 @@ export type DeckCreateWithoutShareLinkInput = {
|
||||||
group?: Prisma.MaterialGroupCreateNestedOneWithoutDecksInput
|
group?: Prisma.MaterialGroupCreateNestedOneWithoutDecksInput
|
||||||
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
|
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
|
||||||
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
|
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckUncheckedCreateWithoutShareLinkInput = {
|
export type DeckUncheckedCreateWithoutShareLinkInput = {
|
||||||
|
|
@ -784,6 +816,7 @@ export type DeckUncheckedCreateWithoutShareLinkInput = {
|
||||||
groupId?: string | null
|
groupId?: string | null
|
||||||
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
|
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
|
||||||
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
|
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckCreateOrConnectWithoutShareLinkInput = {
|
export type DeckCreateOrConnectWithoutShareLinkInput = {
|
||||||
|
|
@ -812,6 +845,7 @@ export type DeckUpdateWithoutShareLinkInput = {
|
||||||
group?: Prisma.MaterialGroupUpdateOneWithoutDecksNestedInput
|
group?: Prisma.MaterialGroupUpdateOneWithoutDecksNestedInput
|
||||||
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
|
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
|
||||||
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
|
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckUncheckedUpdateWithoutShareLinkInput = {
|
export type DeckUncheckedUpdateWithoutShareLinkInput = {
|
||||||
|
|
@ -824,6 +858,7 @@ export type DeckUncheckedUpdateWithoutShareLinkInput = {
|
||||||
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
|
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
|
||||||
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
|
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckCreateWithoutGroupInput = {
|
export type DeckCreateWithoutGroupInput = {
|
||||||
|
|
@ -836,6 +871,7 @@ export type DeckCreateWithoutGroupInput = {
|
||||||
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
|
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
|
||||||
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
|
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
|
||||||
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
|
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckUncheckedCreateWithoutGroupInput = {
|
export type DeckUncheckedCreateWithoutGroupInput = {
|
||||||
|
|
@ -848,6 +884,7 @@ export type DeckUncheckedCreateWithoutGroupInput = {
|
||||||
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
|
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
|
||||||
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
|
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
|
||||||
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
|
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckCreateOrConnectWithoutGroupInput = {
|
export type DeckCreateOrConnectWithoutGroupInput = {
|
||||||
|
|
@ -875,6 +912,74 @@ export type DeckUpdateManyWithWhereWithoutGroupInput = {
|
||||||
data: Prisma.XOR<Prisma.DeckUpdateManyMutationInput, Prisma.DeckUncheckedUpdateManyWithoutGroupInput>
|
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 = {
|
export type DeckCreateManyClassInput = {
|
||||||
id?: string
|
id?: string
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -894,6 +999,7 @@ export type DeckUpdateWithoutClassInput = {
|
||||||
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
|
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
|
||||||
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
|
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
|
||||||
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
|
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckUncheckedUpdateWithoutClassInput = {
|
export type DeckUncheckedUpdateWithoutClassInput = {
|
||||||
|
|
@ -906,6 +1012,7 @@ export type DeckUncheckedUpdateWithoutClassInput = {
|
||||||
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
|
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
|
||||||
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
|
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
|
||||||
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
|
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckUncheckedUpdateManyWithoutClassInput = {
|
export type DeckUncheckedUpdateManyWithoutClassInput = {
|
||||||
|
|
@ -936,6 +1043,7 @@ export type DeckUpdateWithoutGroupInput = {
|
||||||
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
|
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
|
||||||
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
|
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
|
||||||
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
|
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckUncheckedUpdateWithoutGroupInput = {
|
export type DeckUncheckedUpdateWithoutGroupInput = {
|
||||||
|
|
@ -948,6 +1056,7 @@ export type DeckUncheckedUpdateWithoutGroupInput = {
|
||||||
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
|
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
|
||||||
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
|
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
|
||||||
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
|
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
|
||||||
|
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckUncheckedUpdateManyWithoutGroupInput = {
|
export type DeckUncheckedUpdateManyWithoutGroupInput = {
|
||||||
|
|
@ -967,11 +1076,13 @@ export type DeckUncheckedUpdateManyWithoutGroupInput = {
|
||||||
export type DeckCountOutputType = {
|
export type DeckCountOutputType = {
|
||||||
cards: number
|
cards: number
|
||||||
progress: number
|
progress: number
|
||||||
|
spacedRepetitionMemberships: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeckCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
export type DeckCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
cards?: boolean | DeckCountOutputTypeCountCardsArgs
|
cards?: boolean | DeckCountOutputTypeCountCardsArgs
|
||||||
progress?: boolean | DeckCountOutputTypeCountProgressArgs
|
progress?: boolean | DeckCountOutputTypeCountProgressArgs
|
||||||
|
spacedRepetitionMemberships?: boolean | DeckCountOutputTypeCountSpacedRepetitionMembershipsArgs
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -998,6 +1109,13 @@ export type DeckCountOutputTypeCountProgressArgs<ExtArgs extends runtime.Types.E
|
||||||
where?: Prisma.StudyProgressWhereInput
|
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<{
|
export type DeckSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||||
id?: boolean
|
id?: boolean
|
||||||
|
|
@ -1012,6 +1130,7 @@ export type DeckSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
|
||||||
cards?: boolean | Prisma.Deck$cardsArgs<ExtArgs>
|
cards?: boolean | Prisma.Deck$cardsArgs<ExtArgs>
|
||||||
shareLink?: boolean | Prisma.Deck$shareLinkArgs<ExtArgs>
|
shareLink?: boolean | Prisma.Deck$shareLinkArgs<ExtArgs>
|
||||||
progress?: boolean | Prisma.Deck$progressArgs<ExtArgs>
|
progress?: boolean | Prisma.Deck$progressArgs<ExtArgs>
|
||||||
|
spacedRepetitionMemberships?: boolean | Prisma.Deck$spacedRepetitionMembershipsArgs<ExtArgs>
|
||||||
_count?: boolean | Prisma.DeckCountOutputTypeDefaultArgs<ExtArgs>
|
_count?: boolean | Prisma.DeckCountOutputTypeDefaultArgs<ExtArgs>
|
||||||
}, ExtArgs["result"]["deck"]>
|
}, ExtArgs["result"]["deck"]>
|
||||||
|
|
||||||
|
|
@ -1056,6 +1175,7 @@ export type DeckInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
||||||
cards?: boolean | Prisma.Deck$cardsArgs<ExtArgs>
|
cards?: boolean | Prisma.Deck$cardsArgs<ExtArgs>
|
||||||
shareLink?: boolean | Prisma.Deck$shareLinkArgs<ExtArgs>
|
shareLink?: boolean | Prisma.Deck$shareLinkArgs<ExtArgs>
|
||||||
progress?: boolean | Prisma.Deck$progressArgs<ExtArgs>
|
progress?: boolean | Prisma.Deck$progressArgs<ExtArgs>
|
||||||
|
spacedRepetitionMemberships?: boolean | Prisma.Deck$spacedRepetitionMembershipsArgs<ExtArgs>
|
||||||
_count?: boolean | Prisma.DeckCountOutputTypeDefaultArgs<ExtArgs>
|
_count?: boolean | Prisma.DeckCountOutputTypeDefaultArgs<ExtArgs>
|
||||||
}
|
}
|
||||||
export type DeckIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
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>[]
|
cards: Prisma.$FlashcardPayload<ExtArgs>[]
|
||||||
shareLink: Prisma.$ShareLinkPayload<ExtArgs> | null
|
shareLink: Prisma.$ShareLinkPayload<ExtArgs> | null
|
||||||
progress: Prisma.$StudyProgressPayload<ExtArgs>[]
|
progress: Prisma.$StudyProgressPayload<ExtArgs>[]
|
||||||
|
spacedRepetitionMemberships: Prisma.$SpacedRepetitionSetDeckPayload<ExtArgs>[]
|
||||||
}
|
}
|
||||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||||
id: string
|
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>
|
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>
|
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>
|
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.
|
* Attaches callbacks for the resolution and/or rejection of the Promise.
|
||||||
* @param onfulfilled The callback to execute when the Promise is resolved.
|
* @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[]
|
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
|
* Deck without action
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,7 @@ export type FlashcardWhereInput = {
|
||||||
back?: Prisma.StringFilter<"Flashcard"> | string
|
back?: Prisma.StringFilter<"Flashcard"> | string
|
||||||
sortOrder?: Prisma.IntFilter<"Flashcard"> | number
|
sortOrder?: Prisma.IntFilter<"Flashcard"> | number
|
||||||
deck?: Prisma.XOR<Prisma.DeckScalarRelationFilter, Prisma.DeckWhereInput>
|
deck?: Prisma.XOR<Prisma.DeckScalarRelationFilter, Prisma.DeckWhereInput>
|
||||||
|
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateListRelationFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FlashcardOrderByWithRelationInput = {
|
export type FlashcardOrderByWithRelationInput = {
|
||||||
|
|
@ -226,6 +227,7 @@ export type FlashcardOrderByWithRelationInput = {
|
||||||
back?: Prisma.SortOrder
|
back?: Prisma.SortOrder
|
||||||
sortOrder?: Prisma.SortOrder
|
sortOrder?: Prisma.SortOrder
|
||||||
deck?: Prisma.DeckOrderByWithRelationInput
|
deck?: Prisma.DeckOrderByWithRelationInput
|
||||||
|
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateOrderByRelationAggregateInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FlashcardWhereUniqueInput = Prisma.AtLeast<{
|
export type FlashcardWhereUniqueInput = Prisma.AtLeast<{
|
||||||
|
|
@ -238,6 +240,7 @@ export type FlashcardWhereUniqueInput = Prisma.AtLeast<{
|
||||||
back?: Prisma.StringFilter<"Flashcard"> | string
|
back?: Prisma.StringFilter<"Flashcard"> | string
|
||||||
sortOrder?: Prisma.IntFilter<"Flashcard"> | number
|
sortOrder?: Prisma.IntFilter<"Flashcard"> | number
|
||||||
deck?: Prisma.XOR<Prisma.DeckScalarRelationFilter, Prisma.DeckWhereInput>
|
deck?: Prisma.XOR<Prisma.DeckScalarRelationFilter, Prisma.DeckWhereInput>
|
||||||
|
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateListRelationFilter
|
||||||
}, "id">
|
}, "id">
|
||||||
|
|
||||||
export type FlashcardOrderByWithAggregationInput = {
|
export type FlashcardOrderByWithAggregationInput = {
|
||||||
|
|
@ -270,6 +273,7 @@ export type FlashcardCreateInput = {
|
||||||
back: string
|
back: string
|
||||||
sortOrder?: number
|
sortOrder?: number
|
||||||
deck: Prisma.DeckCreateNestedOneWithoutCardsInput
|
deck: Prisma.DeckCreateNestedOneWithoutCardsInput
|
||||||
|
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateCreateNestedManyWithoutFlashcardInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FlashcardUncheckedCreateInput = {
|
export type FlashcardUncheckedCreateInput = {
|
||||||
|
|
@ -278,6 +282,7 @@ export type FlashcardUncheckedCreateInput = {
|
||||||
front: string
|
front: string
|
||||||
back: string
|
back: string
|
||||||
sortOrder?: number
|
sortOrder?: number
|
||||||
|
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUncheckedCreateNestedManyWithoutFlashcardInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FlashcardUpdateInput = {
|
export type FlashcardUpdateInput = {
|
||||||
|
|
@ -286,6 +291,7 @@ export type FlashcardUpdateInput = {
|
||||||
back?: Prisma.StringFieldUpdateOperationsInput | string
|
back?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
|
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
deck?: Prisma.DeckUpdateOneRequiredWithoutCardsNestedInput
|
deck?: Prisma.DeckUpdateOneRequiredWithoutCardsNestedInput
|
||||||
|
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUpdateManyWithoutFlashcardNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FlashcardUncheckedUpdateInput = {
|
export type FlashcardUncheckedUpdateInput = {
|
||||||
|
|
@ -294,6 +300,7 @@ export type FlashcardUncheckedUpdateInput = {
|
||||||
front?: Prisma.StringFieldUpdateOperationsInput | string
|
front?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
back?: Prisma.StringFieldUpdateOperationsInput | string
|
back?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
|
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
|
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUncheckedUpdateManyWithoutFlashcardNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FlashcardCreateManyInput = {
|
export type FlashcardCreateManyInput = {
|
||||||
|
|
@ -361,6 +368,11 @@ export type FlashcardSumOrderByAggregateInput = {
|
||||||
sortOrder?: Prisma.SortOrder
|
sortOrder?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FlashcardScalarRelationFilter = {
|
||||||
|
is?: Prisma.FlashcardWhereInput
|
||||||
|
isNot?: Prisma.FlashcardWhereInput
|
||||||
|
}
|
||||||
|
|
||||||
export type FlashcardCreateNestedManyWithoutDeckInput = {
|
export type FlashcardCreateNestedManyWithoutDeckInput = {
|
||||||
create?: Prisma.XOR<Prisma.FlashcardCreateWithoutDeckInput, Prisma.FlashcardUncheckedCreateWithoutDeckInput> | Prisma.FlashcardCreateWithoutDeckInput[] | Prisma.FlashcardUncheckedCreateWithoutDeckInput[]
|
create?: Prisma.XOR<Prisma.FlashcardCreateWithoutDeckInput, Prisma.FlashcardUncheckedCreateWithoutDeckInput> | Prisma.FlashcardCreateWithoutDeckInput[] | Prisma.FlashcardUncheckedCreateWithoutDeckInput[]
|
||||||
connectOrCreate?: Prisma.FlashcardCreateOrConnectWithoutDeckInput | Prisma.FlashcardCreateOrConnectWithoutDeckInput[]
|
connectOrCreate?: Prisma.FlashcardCreateOrConnectWithoutDeckInput | Prisma.FlashcardCreateOrConnectWithoutDeckInput[]
|
||||||
|
|
@ -403,11 +415,26 @@ export type FlashcardUncheckedUpdateManyWithoutDeckNestedInput = {
|
||||||
deleteMany?: Prisma.FlashcardScalarWhereInput | Prisma.FlashcardScalarWhereInput[]
|
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 = {
|
export type FlashcardCreateWithoutDeckInput = {
|
||||||
id?: string
|
id?: string
|
||||||
front: string
|
front: string
|
||||||
back: string
|
back: string
|
||||||
sortOrder?: number
|
sortOrder?: number
|
||||||
|
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateCreateNestedManyWithoutFlashcardInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FlashcardUncheckedCreateWithoutDeckInput = {
|
export type FlashcardUncheckedCreateWithoutDeckInput = {
|
||||||
|
|
@ -415,6 +442,7 @@ export type FlashcardUncheckedCreateWithoutDeckInput = {
|
||||||
front: string
|
front: string
|
||||||
back: string
|
back: string
|
||||||
sortOrder?: number
|
sortOrder?: number
|
||||||
|
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUncheckedCreateNestedManyWithoutFlashcardInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FlashcardCreateOrConnectWithoutDeckInput = {
|
export type FlashcardCreateOrConnectWithoutDeckInput = {
|
||||||
|
|
@ -453,6 +481,54 @@ export type FlashcardScalarWhereInput = {
|
||||||
sortOrder?: Prisma.IntFilter<"Flashcard"> | number
|
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 = {
|
export type FlashcardCreateManyDeckInput = {
|
||||||
id?: string
|
id?: string
|
||||||
front: string
|
front: string
|
||||||
|
|
@ -465,6 +541,7 @@ export type FlashcardUpdateWithoutDeckInput = {
|
||||||
front?: Prisma.StringFieldUpdateOperationsInput | string
|
front?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
back?: Prisma.StringFieldUpdateOperationsInput | string
|
back?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
|
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
|
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUpdateManyWithoutFlashcardNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FlashcardUncheckedUpdateWithoutDeckInput = {
|
export type FlashcardUncheckedUpdateWithoutDeckInput = {
|
||||||
|
|
@ -472,6 +549,7 @@ export type FlashcardUncheckedUpdateWithoutDeckInput = {
|
||||||
front?: Prisma.StringFieldUpdateOperationsInput | string
|
front?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
back?: Prisma.StringFieldUpdateOperationsInput | string
|
back?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
|
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
|
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUncheckedUpdateManyWithoutFlashcardNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FlashcardUncheckedUpdateManyWithoutDeckInput = {
|
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<{
|
export type FlashcardSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||||
id?: boolean
|
id?: boolean
|
||||||
|
|
@ -490,6 +597,8 @@ export type FlashcardSelect<ExtArgs extends runtime.Types.Extensions.InternalArg
|
||||||
back?: boolean
|
back?: boolean
|
||||||
sortOrder?: boolean
|
sortOrder?: boolean
|
||||||
deck?: boolean | Prisma.DeckDefaultArgs<ExtArgs>
|
deck?: boolean | Prisma.DeckDefaultArgs<ExtArgs>
|
||||||
|
spacedRepetitionStates?: boolean | Prisma.Flashcard$spacedRepetitionStatesArgs<ExtArgs>
|
||||||
|
_count?: boolean | Prisma.FlashcardCountOutputTypeDefaultArgs<ExtArgs>
|
||||||
}, ExtArgs["result"]["flashcard"]>
|
}, ExtArgs["result"]["flashcard"]>
|
||||||
|
|
||||||
export type FlashcardSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
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 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> = {
|
export type FlashcardInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
deck?: boolean | Prisma.DeckDefaultArgs<ExtArgs>
|
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> = {
|
export type FlashcardIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
deck?: boolean | Prisma.DeckDefaultArgs<ExtArgs>
|
deck?: boolean | Prisma.DeckDefaultArgs<ExtArgs>
|
||||||
|
|
@ -533,6 +644,7 @@ export type $FlashcardPayload<ExtArgs extends runtime.Types.Extensions.InternalA
|
||||||
name: "Flashcard"
|
name: "Flashcard"
|
||||||
objects: {
|
objects: {
|
||||||
deck: Prisma.$DeckPayload<ExtArgs>
|
deck: Prisma.$DeckPayload<ExtArgs>
|
||||||
|
spacedRepetitionStates: Prisma.$SpacedRepetitionCardStatePayload<ExtArgs>[]
|
||||||
}
|
}
|
||||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||||
id: string
|
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> {
|
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"
|
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>
|
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.
|
* Attaches callbacks for the resolution and/or rejection of the Promise.
|
||||||
* @param onfulfilled The callback to execute when the Promise is resolved.
|
* @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
|
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
|
* 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