Compare commits
1 commit
main
...
backup-mai
| Author | SHA1 | Date | |
|---|---|---|---|
| bd071b3906 |
119 changed files with 449 additions and 19125 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -13,12 +13,6 @@
|
||||||
# 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,350 +1,5 @@
|
||||||
# Study Desk - AI Contributor Guide
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
This file is the operating guide for AI agents and human contributors working in this repository.
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
## 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
Normal file
BIN
dev.db
Normal file
Binary file not shown.
840
package-lock.json
generated
840
package-lock.json
generated
|
|
@ -22,7 +22,6 @@
|
||||||
"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,8 +33,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"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
|
|
@ -1351,16 +1349,6 @@
|
||||||
"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",
|
||||||
|
|
@ -1749,293 +1737,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
|
||||||
"version": "1.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
|
||||||
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
|
||||||
"version": "1.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
|
|
||||||
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-darwin-x64": {
|
|
||||||
"version": "1.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
|
|
||||||
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
|
||||||
"version": "1.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
|
|
||||||
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
|
||||||
"version": "1.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
|
|
||||||
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
|
||||||
"version": "1.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
|
|
||||||
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
|
||||||
"version": "1.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
|
|
||||||
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
|
||||||
"version": "1.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
|
|
||||||
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
|
||||||
"version": "1.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
|
|
||||||
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
|
||||||
"version": "1.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
|
|
||||||
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
|
||||||
"version": "1.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
|
|
||||||
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
|
||||||
"version": "1.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
|
|
||||||
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openharmony"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
|
||||||
"version": "1.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
|
|
||||||
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
|
|
||||||
"cpu": [
|
|
||||||
"wasm32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/core": "1.11.1",
|
|
||||||
"@emnapi/runtime": "1.11.1",
|
|
||||||
"@napi-rs/wasm-runtime": "^1.1.6"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
|
||||||
"version": "1.11.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
|
||||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/wasi-threads": "1.2.2",
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
|
||||||
"version": "1.11.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
|
||||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
|
||||||
"version": "1.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
|
|
||||||
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
|
||||||
"version": "1.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
|
|
||||||
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/pluginutils": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@rtsao/scc": {
|
"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",
|
||||||
|
|
@ -2341,17 +2042,6 @@
|
||||||
"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",
|
||||||
|
|
@ -2361,13 +2051,6 @@
|
||||||
"@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",
|
||||||
|
|
@ -2427,7 +2110,6 @@
|
||||||
"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"
|
||||||
}
|
}
|
||||||
|
|
@ -3108,119 +2790,6 @@
|
||||||
"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",
|
||||||
|
|
@ -3471,16 +3040,6 @@
|
||||||
"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",
|
||||||
|
|
@ -3836,16 +3395,6 @@
|
||||||
"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",
|
||||||
|
|
@ -4512,13 +4061,6 @@
|
||||||
"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",
|
||||||
|
|
@ -5013,16 +4555,6 @@
|
||||||
"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",
|
||||||
|
|
@ -5042,16 +4574,6 @@
|
||||||
"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",
|
||||||
|
|
@ -5275,21 +4797,6 @@
|
||||||
"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",
|
||||||
|
|
@ -8168,20 +7675,6 @@
|
||||||
"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",
|
||||||
|
|
@ -8914,40 +8407,6 @@
|
||||||
"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",
|
||||||
|
|
@ -9280,13 +8739,6 @@
|
||||||
"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",
|
||||||
|
|
@ -9381,13 +8833,6 @@
|
||||||
"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",
|
||||||
|
|
@ -9685,23 +9130,6 @@
|
||||||
"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",
|
||||||
|
|
@ -9751,16 +9179,6 @@
|
||||||
"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",
|
||||||
|
|
@ -9807,15 +9225,6 @@
|
||||||
"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",
|
||||||
|
|
@ -10237,236 +9646,6 @@
|
||||||
"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",
|
||||||
|
|
@ -10571,23 +9750,6 @@
|
||||||
"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,8 +6,7 @@
|
||||||
"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",
|
||||||
|
|
@ -24,7 +23,6 @@
|
||||||
"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": {
|
||||||
|
|
@ -36,7 +34,6 @@
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "StudyActivity" (
|
|
||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
|
||||||
"type" TEXT NOT NULL,
|
|
||||||
"occurredAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "StudyActivity_occurredAt_idx" ON "StudyActivity"("occurredAt");
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "SpacedRepetitionSet" (
|
|
||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
|
||||||
"classId" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"description" TEXT,
|
|
||||||
"newCardsPerDay" INTEGER NOT NULL DEFAULT 30,
|
|
||||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" DATETIME NOT NULL,
|
|
||||||
CONSTRAINT "SpacedRepetitionSet_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "SpacedRepetitionSetDeck" (
|
|
||||||
"setId" TEXT NOT NULL,
|
|
||||||
"deckId" TEXT NOT NULL,
|
|
||||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"addedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
PRIMARY KEY ("setId", "deckId"),
|
|
||||||
CONSTRAINT "SpacedRepetitionSetDeck_setId_fkey" FOREIGN KEY ("setId") REFERENCES "SpacedRepetitionSet" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
CONSTRAINT "SpacedRepetitionSetDeck_deckId_fkey" FOREIGN KEY ("deckId") REFERENCES "Deck" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "SpacedRepetitionCardState" (
|
|
||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
|
||||||
"setId" TEXT NOT NULL,
|
|
||||||
"flashcardId" TEXT NOT NULL,
|
|
||||||
"due" DATETIME NOT NULL,
|
|
||||||
"stability" REAL NOT NULL,
|
|
||||||
"difficulty" REAL NOT NULL,
|
|
||||||
"elapsedDays" INTEGER NOT NULL,
|
|
||||||
"scheduledDays" INTEGER NOT NULL,
|
|
||||||
"learningSteps" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"reps" INTEGER NOT NULL,
|
|
||||||
"lapses" INTEGER NOT NULL,
|
|
||||||
"state" INTEGER NOT NULL,
|
|
||||||
"firstReviewedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"lastReview" DATETIME,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" DATETIME NOT NULL,
|
|
||||||
CONSTRAINT "SpacedRepetitionCardState_setId_fkey" FOREIGN KEY ("setId") REFERENCES "SpacedRepetitionSet" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
CONSTRAINT "SpacedRepetitionCardState_flashcardId_fkey" FOREIGN KEY ("flashcardId") REFERENCES "Flashcard" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "SpacedRepetitionSet_classId_sortOrder_idx" ON "SpacedRepetitionSet"("classId", "sortOrder");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "SpacedRepetitionSetDeck_deckId_idx" ON "SpacedRepetitionSetDeck"("deckId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "SpacedRepetitionCardState_setId_due_idx" ON "SpacedRepetitionCardState"("setId", "due");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "SpacedRepetitionCardState_setId_firstReviewedAt_idx" ON "SpacedRepetitionCardState"("setId", "firstReviewedAt");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "SpacedRepetitionCardState_flashcardId_idx" ON "SpacedRepetitionCardState"("flashcardId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "SpacedRepetitionCardState_setId_flashcardId_key" ON "SpacedRepetitionCardState"("setId", "flashcardId");
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "ArcadePack" (
|
|
||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
|
||||||
"classId" TEXT NOT NULL,
|
|
||||||
"gameType" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"description" TEXT,
|
|
||||||
"schemaVersion" INTEGER NOT NULL,
|
|
||||||
"sourceJson" TEXT NOT NULL,
|
|
||||||
"normalizedJson" TEXT NOT NULL,
|
|
||||||
"validationReportJson" TEXT NOT NULL,
|
|
||||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" DATETIME NOT NULL,
|
|
||||||
CONSTRAINT "ArcadePack_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "ArcadeAttempt" (
|
|
||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
|
||||||
"arcadePackId" TEXT NOT NULL,
|
|
||||||
"mode" TEXT NOT NULL,
|
|
||||||
"score" INTEGER NOT NULL,
|
|
||||||
"maxScore" INTEGER NOT NULL,
|
|
||||||
"accuracy" REAL NOT NULL,
|
|
||||||
"durationSeconds" INTEGER NOT NULL,
|
|
||||||
"mistakes" INTEGER NOT NULL,
|
|
||||||
"hintsUsed" INTEGER NOT NULL,
|
|
||||||
"settingsJson" TEXT NOT NULL,
|
|
||||||
"resultsJson" TEXT NOT NULL,
|
|
||||||
"seed" TEXT NOT NULL,
|
|
||||||
"completedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT "ArcadeAttempt_arcadePackId_fkey" FOREIGN KEY ("arcadePackId") REFERENCES "ArcadePack" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "ArcadePack_classId_gameType_sortOrder_idx" ON "ArcadePack"("classId", "gameType", "sortOrder");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "ArcadeAttempt_arcadePackId_completedAt_idx" ON "ArcadeAttempt"("arcadePackId", "completedAt");
|
|
||||||
|
|
@ -14,11 +14,9 @@ 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 {
|
||||||
|
|
@ -29,13 +27,12 @@ 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 {
|
||||||
|
|
@ -45,8 +42,7 @@ 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 {
|
||||||
|
|
@ -57,9 +53,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?
|
||||||
|
|
@ -145,121 +141,16 @@ 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])
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { ARCADE_RENDERERS } from "@/components/arcade/rendererRegistry";
|
|
||||||
import { getArcadePack } from "@/services/arcadeService";
|
|
||||||
|
|
||||||
export default async function ConnectionsPlayPage(
|
|
||||||
props: PageProps<"/[classSlug]/arcade/connections/[packId]/play">
|
|
||||||
) {
|
|
||||||
const [{ classSlug, packId }, query] = await Promise.all([props.params, props.searchParams]);
|
|
||||||
const pack = await getArcadePack(packId);
|
|
||||||
if (!pack || pack.class.slug !== classSlug || pack.gameType !== "connections" || pack.normalized.type !== "connections") notFound();
|
|
||||||
const normalized = pack.normalized;
|
|
||||||
const mistakesValue = Number(query.mistakes);
|
|
||||||
const allowedMistakes = Number.isInteger(mistakesValue) && mistakesValue >= 1 && mistakesValue <= 8
|
|
||||||
? mistakesValue
|
|
||||||
: normalized.settings.allowedMistakes;
|
|
||||||
const Renderer = ARCADE_RENDERERS.connections;
|
|
||||||
return <Renderer seed={randomUUID()} packId={pack.id} packName={pack.name} classSlug={classSlug} pack={normalized} settings={{ allowedMistakes, oneAwayFeedback: query.oneAway !== "0" }} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { ConnectionsHub } from "@/components/arcade/ConnectionsHub";
|
|
||||||
import { getClassBySlug } from "@/services/classService";
|
|
||||||
import { listArcadePacks } from "@/services/arcadeService";
|
|
||||||
|
|
||||||
export default async function ConnectionsHubPage(props: PageProps<"/[classSlug]/arcade/connections">) {
|
|
||||||
const { classSlug } = await props.params;
|
|
||||||
const classItem = await getClassBySlug(classSlug);
|
|
||||||
if (!classItem) notFound();
|
|
||||||
const packs = await listArcadePacks(classItem.id, "connections");
|
|
||||||
return <ConnectionsHub classId={classItem.id} classSlug={classSlug} initialPacks={packs} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { ARCADE_RENDERERS } from "@/components/arcade/rendererRegistry";
|
|
||||||
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
|
|
||||||
import type { CrosswordSize, NormalizedCrosswordPack } from "@/types/arcade";
|
|
||||||
import { getArcadePack } from "@/services/arcadeService";
|
|
||||||
|
|
||||||
const SIZES = new Set<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" }} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { CrosswordHub } from "@/components/arcade/CrosswordHub";
|
|
||||||
import { getClassBySlug } from "@/services/classService";
|
|
||||||
import { listArcadePacks } from "@/services/arcadeService";
|
|
||||||
|
|
||||||
export default async function CrosswordHubPage(props: { params: Promise<{ classSlug: string }> }) {
|
|
||||||
const { classSlug } = await props.params;
|
|
||||||
const classItem = await getClassBySlug(classSlug);
|
|
||||||
if (!classItem) notFound();
|
|
||||||
const packs = await listArcadePacks(classItem.id, "crossword");
|
|
||||||
return <CrosswordHub classId={classItem.id} classSlug={classSlug} initialPacks={packs} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export default function ArcadeLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
return <div className="arcade-route">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -27,7 +27,6 @@ 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;
|
||||||
|
|
@ -66,14 +65,7 @@ function getProgressLabel(deck: DeckItem) {
|
||||||
return `${current}/${total}, ${modeLabel} · ${correctCount} ✓`;
|
return `${current}/${total}, ${modeLabel} · ${correctCount} ✓`;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SortableDeckCardProps {
|
function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
|
||||||
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),
|
||||||
|
|
@ -107,10 +99,9 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCar
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 border-t border-border-light pt-4 pl-7 sm:flex-row sm:items-center">
|
<div className="flex items-center gap-2 pt-4 border-t border-border-light pl-7">
|
||||||
<div className="flex w-full min-w-0 gap-2 sm:flex-1">
|
{deck.progress?.length ? (
|
||||||
{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>
|
||||||
|
|
@ -128,26 +119,22 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCar
|
||||||
>
|
>
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
<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 className="flex items-center justify-end gap-2">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<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">
|
<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 className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</svg>
|
||||||
<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>
|
||||||
</svg>
|
<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">
|
||||||
</button>
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<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">
|
<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 className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</svg>
|
||||||
<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>
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<ShareMenu targetType="DECK" contentId={deck.id} classSlug={classSlug} compact />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -322,6 +309,8 @@ 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;
|
||||||
|
|
@ -350,7 +339,7 @@ export default function FlashcardsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const affectedGroups = new Set([activeDeck.groupId, targetGroupId]);
|
const affectedGroups = new Set([activeDeck.groupId, targetGroupId]);
|
||||||
const updates: ReorderItem[] = [];
|
const updates: any[] = [];
|
||||||
|
|
||||||
affectedGroups.forEach(gId => {
|
affectedGroups.forEach(gId => {
|
||||||
const gItems = newItems.filter(d => d.groupId === gId);
|
const gItems = newItems.filter(d => d.groupId === gId);
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ export default async function ClassLayout(props: LayoutProps<"/[classSlug]">) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page">
|
||||||
{/* Dynamic class header */}
|
{/* Dynamic Header & Tabs */}
|
||||||
<ClassHeader className={classData.name} />
|
<ClassHeader classSlug={classSlug} className={classData.name} />
|
||||||
|
|
||||||
{/* Page content */}
|
{/* Page content */}
|
||||||
{props.children}
|
{props.children}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ 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;
|
||||||
|
|
@ -36,12 +35,6 @@ 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 {
|
||||||
|
|
@ -52,14 +45,7 @@ interface MaterialGroup {
|
||||||
|
|
||||||
const cache: Record<string, { quizzes: QuizItem[]; groups: MaterialGroup[] }> = {};
|
const cache: Record<string, { quizzes: QuizItem[]; groups: MaterialGroup[] }> = {};
|
||||||
|
|
||||||
interface SortableQuizCardProps {
|
function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) {
|
||||||
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),
|
||||||
|
|
@ -93,48 +79,20 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCar
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-col gap-2 border-t border-border-light pt-4 pl-7 sm:flex-row sm:items-center">
|
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-border-light pl-7">
|
||||||
<div className="flex w-full min-w-0 gap-2 sm:flex-1">
|
<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">
|
||||||
{quiz.progress?.length ? (
|
Start Quiz
|
||||||
<>
|
</Link>
|
||||||
<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">
|
<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">
|
||||||
Continue
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</Link>
|
<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
|
</svg>
|
||||||
onClick={async () => {
|
</button>
|
||||||
if (!confirm("Start over from the beginning? This will clear your current progress for this quiz.")) return;
|
<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">
|
||||||
await fetch("/api/progress", {
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
method: "DELETE",
|
<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" />
|
||||||
headers: { "Content-Type": "application/json" },
|
</svg>
|
||||||
body: JSON.stringify({ contentType: "QUIZ", contentId: quiz.id, mode: "SEQUENTIAL" }),
|
</button>
|
||||||
}).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>
|
||||||
|
|
@ -320,6 +278,8 @@ 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) {
|
||||||
|
|
@ -351,7 +311,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: ReorderItem[] = [];
|
const updates: any[] = [];
|
||||||
|
|
||||||
affectedGroups.forEach(gId => {
|
affectedGroups.forEach(gId => {
|
||||||
const gItems = newItems.filter(q => q.groupId === gId);
|
const gItems = newItems.filter(q => q.groupId === gId);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { SpacedRepetitionViewer } from "@/components/spaced-repetition/SpacedRepetitionViewer";
|
|
||||||
|
|
||||||
export default function SpacedRepetitionStudyPage() {
|
|
||||||
return <SpacedRepetitionViewer />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { SpacedRepetitionSets } from "@/components/spaced-repetition/SpacedRepetitionSets";
|
|
||||||
|
|
||||||
export default function SpacedRepetitionPage() {
|
|
||||||
return <SpacedRepetitionSets />;
|
|
||||||
}
|
|
||||||
|
|
@ -2,17 +2,12 @@
|
||||||
|
|
||||||
import { useEffect, useState } 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"];
|
const accents = ["#4f46e5", "#f97360", "#25845f", "#c27718", "#8b5cf6", "#0284c7"];
|
||||||
|
|
@ -61,9 +56,33 @@ export default function HomePage() {
|
||||||
} finally { setSavingEdit(false); }
|
} finally { setSavingEdit(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deckTotal = classes.reduce((sum, item) => sum + item._count.decks, 0);
|
||||||
|
const quizTotal = classes.reduce((sum, item) => sum + item._count.quizSets, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page">
|
||||||
<ActivityBanner onNewClass={() => setShowCreate(true)} />
|
<section className="relative mb-10 overflow-hidden rounded-[2rem] bg-[#172033] px-6 py-8 text-white shadow-[var(--shadow-card)] sm:px-9 sm:py-10 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 flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="mb-3 text-xs font-bold uppercase tracking-[0.22em] text-white/55">Personal library</p>
|
||||||
|
<h1 className="editorial-title max-w-2xl text-4xl leading-[1.02] sm:text-5xl">What are we learning today?</h1>
|
||||||
|
<p className="mt-4 max-w-xl text-sm leading-6 text-white/65 sm:text-base">Pick up where you left off, sharpen a weak topic, or build something new.</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowCreate(true)} className="inline-flex min-h-12 items-center justify-center gap-2 self-start rounded-xl bg-white px-5 text-sm font-bold text-[#172033] shadow-lg transition-transform hover:-translate-y-0.5 lg:self-auto">
|
||||||
|
<span className="text-xl leading-none">+</span> New class
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="relative mt-8 grid max-w-xl grid-cols-3 gap-3 border-t border-white/12 pt-6">
|
||||||
|
{[[classes.length, "Classes"], [deckTotal, "Decks"], [quizTotal, "Quizzes"]].map(([value, label]) => (
|
||||||
|
<div key={label}>
|
||||||
|
<div className="text-2xl font-extrabold sm:text-3xl">{value}</div>
|
||||||
|
<div className="mt-1 text-xs font-semibold text-white/50">{label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{showCreate && (
|
{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">
|
<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">
|
||||||
|
|
@ -118,14 +137,6 @@ export default function HomePage() {
|
||||||
<div className="mt-3 flex flex-wrap gap-2 text-xs font-semibold text-text-secondary">
|
<div className="mt-3 flex flex-wrap gap-2 text-xs font-semibold text-text-secondary">
|
||||||
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1">{cls._count.decks} {cls._count.decks === 1 ? "deck" : "decks"}</span>
|
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1">{cls._count.decks} {cls._count.decks === 1 ? "deck" : "decks"}</span>
|
||||||
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1">{cls._count.quizSets} {cls._count.quizSets === 1 ? "quiz" : "quizzes"}</span>
|
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1">{cls._count.quizSets} {cls._count.quizSets === 1 ? "quiz" : "quizzes"}</span>
|
||||||
{cls.spacedRepetitionReadyCards > 0 && (
|
|
||||||
<span
|
|
||||||
className="rounded-full bg-error-bg px-2.5 py-1 text-error"
|
|
||||||
title={`${cls.spacedRepetitionDueCards} due, ${cls.spacedRepetitionLearningCards} learning, ${cls.spacedRepetitionNewCards} new`}
|
|
||||||
>
|
|
||||||
{cls.spacedRepetitionReadyCards} {cls.spacedRepetitionReadyCards === 1 ? "card" : "cards"} ready
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</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">
|
<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">
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { studyActivitySchema } from "@/lib/validation/activitySchemas";
|
|
||||||
import * as activityService from "@/services/activityService";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
return NextResponse.json(await activityService.getActivitySummary());
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const parsed = studyActivitySchema.safeParse(await request.json().catch(() => null));
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json({ error: "Invalid activity type" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await activityService.recordActivity(parsed.data.type);
|
|
||||||
return NextResponse.json({ success: true }, { status: 201 });
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
|
|
||||||
import { arcadePreviewRequestSchema } from "@/lib/validation/arcadeSchemas";
|
|
||||||
import * as arcadeService from "@/services/arcadeService";
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const parsed = arcadePreviewRequestSchema.safeParse(await request.json().catch(() => null));
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json({ error: "A game type and raw JSON are required." }, { status: 400 });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return NextResponse.json(arcadeService.previewArcadeImport(parsed.data.gameType, parsed.data.rawJson));
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ArcadeImportError) {
|
|
||||||
return NextResponse.json({ error: error.message, details: error.details }, { status: 400 });
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { arcadeAttemptCreateSchema, crosswordAttemptCreateSchema } from "@/lib/validation/arcadeSchemas";
|
|
||||||
import * as arcadeService from "@/services/arcadeService";
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
_request: NextRequest,
|
|
||||||
context: RouteContext<"/api/arcade/packs/[id]/attempts">
|
|
||||||
) {
|
|
||||||
const { id } = await context.params;
|
|
||||||
const pack = await arcadeService.getArcadePack(id);
|
|
||||||
if (!pack) return NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
|
|
||||||
return NextResponse.json(await arcadeService.listArcadeAttempts(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
request: NextRequest,
|
|
||||||
context: RouteContext<"/api/arcade/packs/[id]/attempts">
|
|
||||||
) {
|
|
||||||
const { id } = await context.params;
|
|
||||||
const pack = await arcadeService.getArcadePack(id);
|
|
||||||
if (!pack) return NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
|
|
||||||
const body = await request.json().catch(() => null);
|
|
||||||
const parsed = pack.gameType === "crossword"
|
|
||||||
? crosswordAttemptCreateSchema.safeParse(body)
|
|
||||||
: arcadeAttemptCreateSchema.safeParse(body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json({ error: "Invalid completed attempt", details: parsed.error.issues }, { status: 400 });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const attempt = pack.gameType === "crossword"
|
|
||||||
? await arcadeService.createCrosswordAttempt(id, parsed.data as Parameters<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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
|
|
||||||
import type { CrosswordSize } from "@/types/arcade";
|
|
||||||
import { getArcadePack } from "@/services/arcadeService";
|
|
||||||
|
|
||||||
const CROSSWORD_SIZES = new Set<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}`));
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { arcadePackUpdateSchema } from "@/lib/validation/arcadeSchemas";
|
|
||||||
import * as arcadeService from "@/services/arcadeService";
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
_request: NextRequest,
|
|
||||||
context: RouteContext<"/api/arcade/packs/[id]">
|
|
||||||
) {
|
|
||||||
const { id } = await context.params;
|
|
||||||
const pack = await arcadeService.getArcadePack(id);
|
|
||||||
return pack
|
|
||||||
? NextResponse.json(pack)
|
|
||||||
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PATCH(
|
|
||||||
request: NextRequest,
|
|
||||||
context: RouteContext<"/api/arcade/packs/[id]">
|
|
||||||
) {
|
|
||||||
const { id } = await context.params;
|
|
||||||
const parsed = arcadePackUpdateSchema.safeParse(await request.json().catch(() => null));
|
|
||||||
if (!parsed.success) return NextResponse.json({ error: "A valid name is required" }, { status: 400 });
|
|
||||||
const pack = await arcadeService.updateArcadePack(id, parsed.data.name);
|
|
||||||
return pack
|
|
||||||
? NextResponse.json(pack)
|
|
||||||
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
_request: NextRequest,
|
|
||||||
context: RouteContext<"/api/arcade/packs/[id]">
|
|
||||||
) {
|
|
||||||
const { id } = await context.params;
|
|
||||||
const deleted = await arcadeService.deleteArcadePack(id);
|
|
||||||
return deleted
|
|
||||||
? NextResponse.json({ success: true })
|
|
||||||
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
|
|
||||||
import { arcadePackCreateSchema } from "@/lib/validation/arcadeSchemas";
|
|
||||||
import * as arcadeService from "@/services/arcadeService";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const params = new URL(request.url).searchParams;
|
|
||||||
const classId = params.get("classId");
|
|
||||||
const gameType = params.get("gameType");
|
|
||||||
if (!classId || (gameType !== "connections" && gameType !== "crossword")) {
|
|
||||||
return NextResponse.json({ error: "Valid classId and gameType values are required." }, { status: 400 });
|
|
||||||
}
|
|
||||||
return NextResponse.json(await arcadeService.listArcadePacks(classId, gameType));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const parsed = arcadePackCreateSchema.safeParse(await request.json().catch(() => null));
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json({ error: "Invalid Arcade pack request", details: parsed.error.issues }, { status: 400 });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const packs = await arcadeService.createArcadePacks(parsed.data);
|
|
||||||
return NextResponse.json({ packs, count: packs.length }, { status: 201 });
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ArcadeImportError) {
|
|
||||||
return NextResponse.json({ error: error.message, details: error.details }, { status: 400 });
|
|
||||||
}
|
|
||||||
if (error instanceof Error) {
|
|
||||||
const status = error.message === "Class not found" ? 404 : 400;
|
|
||||||
return NextResponse.json({ error: error.message }, { status });
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +1,116 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { loginSchema } from "@/lib/validation/authSchemas";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { createSession } from "@/lib/auth";
|
||||||
import { checkRateLimit } from "@/lib/rateLimiter";
|
import { 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) {
|
||||||
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
// Rate limit by IP
|
||||||
if (!checkRateLimit(`login:${ip}`).allowed) {
|
const forwarded = request.headers.get("x-forwarded-for");
|
||||||
|
const ip = forwarded?.split(",")[0]?.trim() ?? "unknown";
|
||||||
|
const rateCheck = checkRateLimit(ip);
|
||||||
|
|
||||||
|
if (!rateCheck.allowed) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Too many requests. Try again shortly." },
|
{ error: "Too many requests. Try again shortly." },
|
||||||
{ status: 429 }
|
{ status: 429 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = loginSchema.safeParse(await request.json().catch(() => null));
|
const body = await request.json().catch(() => null);
|
||||||
if (!parsed.success) {
|
if (!body?.password || typeof body.password !== "string") {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: parsed.error.issues[0]?.message ?? "Invalid request" },
|
{ error: "Password is required" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await login(parsed.data.password);
|
// Check lockout state
|
||||||
if (!result.ok) {
|
let security = await prisma.authSecurity.findUnique({ where: { id: 1 } });
|
||||||
return NextResponse.json({ error: result.error }, { status: result.status });
|
if (!security) {
|
||||||
|
security = await prisma.authSecurity.create({ data: { id: 1 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (security.lockedUntil && security.lockedUntil > new Date()) {
|
||||||
|
const remainingMs =
|
||||||
|
security.lockedUntil.getTime() - Date.now();
|
||||||
|
const remainingMin = Math.ceil(remainingMs / 60000);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Account locked. Try again in ${remainingMin} minute${remainingMin !== 1 ? "s" : ""}.`,
|
||||||
|
},
|
||||||
|
{ status: 423 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const setting = await prisma.setting.findUnique({
|
||||||
|
where: { key: "admin_password_hash" },
|
||||||
|
});
|
||||||
|
|
||||||
|
let isValid = false;
|
||||||
|
|
||||||
|
if (!setting) {
|
||||||
|
// Initial setup: hash and save the new password
|
||||||
|
const argon2 = await import("argon2");
|
||||||
|
const newHash = await argon2.hash(body.password);
|
||||||
|
await prisma.setting.create({
|
||||||
|
data: {
|
||||||
|
key: "admin_password_hash",
|
||||||
|
value: newHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
isValid = true;
|
||||||
|
} else {
|
||||||
|
// Verify existing password
|
||||||
|
const passwordHash = setting.value;
|
||||||
|
try {
|
||||||
|
const argon2 = await import("argon2");
|
||||||
|
isValid = await argon2.verify(passwordHash, body.password);
|
||||||
|
} catch {
|
||||||
|
isValid = body.password === passwordHash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
const newFailedAttempts = security.failedAttempts + 1;
|
||||||
|
const lockoutMs = getLockoutDuration(newFailedAttempts);
|
||||||
|
const lockedUntil = lockoutMs > 0 ? new Date(Date.now() + lockoutMs) : null;
|
||||||
|
|
||||||
|
await prisma.authSecurity.update({
|
||||||
|
where: { id: 1 },
|
||||||
|
data: {
|
||||||
|
failedAttempts: newFailedAttempts,
|
||||||
|
lockedUntil,
|
||||||
|
lastAttemptAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid password" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success — reset lockout, create session
|
||||||
|
await prisma.authSecurity.update({
|
||||||
|
where: { id: 1 },
|
||||||
|
data: {
|
||||||
|
failedAttempts: 0,
|
||||||
|
lockedUntil: null,
|
||||||
|
lastAttemptAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await createSession();
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { completePasswordResetSchema } from "@/lib/validation/authSchemas";
|
|
||||||
import { completePasswordReset } from "@/services/authService";
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const parsed = completePasswordResetSchema.safeParse(
|
|
||||||
await request.json().catch(() => null)
|
|
||||||
);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: parsed.error.issues[0]?.message ?? "Invalid request" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await completePasswordReset(parsed.data.password))) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Reset authorization is invalid or expired" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { checkRateLimit } from "@/lib/rateLimiter";
|
|
||||||
import { requestPasswordReset } from "@/services/authService";
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
||||||
const rateLimit = checkRateLimit(`password-reset-request:${ip}`, {
|
|
||||||
windowMs: 60_000,
|
|
||||||
maxRequests: 1,
|
|
||||||
});
|
|
||||||
if (!rateLimit.allowed) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "A reset token was requested recently. Try again shortly." },
|
|
||||||
{ status: 429 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await requestPasswordReset())) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Password recovery is unavailable before initial setup." },
|
|
||||||
{ status: 409 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { checkRateLimit } from "@/lib/rateLimiter";
|
|
||||||
import { resetTokenSchema } from "@/lib/validation/authSchemas";
|
|
||||||
import { verifyPasswordResetToken } from "@/services/authService";
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
||||||
if (!checkRateLimit(`password-reset-verify:${ip}`).allowed) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Too many attempts. Try again shortly." },
|
|
||||||
{ status: 429 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = resetTokenSchema.safeParse(await request.json().catch(() => null));
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json({ error: "Invalid or expired reset token" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await verifyPasswordResetToken(parsed.data.token))) {
|
|
||||||
return NextResponse.json({ error: "Invalid or expired reset token" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +1,19 @@
|
||||||
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 parsed = reorderRequestSchema.safeParse(await request.json());
|
const body = await request.json();
|
||||||
if (!parsed.success) {
|
const { items } = body;
|
||||||
|
|
||||||
|
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) =>
|
items.map((item: any) =>
|
||||||
prisma.deck.update({
|
prisma.deck.update({
|
||||||
where: { id: item.id },
|
where: { id: item.id },
|
||||||
data: {
|
data: {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
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,
|
||||||
|
|
@ -11,7 +10,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: Prisma.MaterialGroupUpdateInput = {};
|
const updateData: any = {};
|
||||||
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,6 +1,5 @@
|
||||||
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);
|
||||||
|
|
@ -15,7 +14,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: Prisma.MaterialGroupWhereInput = { classId };
|
const whereClause: any = { classId };
|
||||||
if (type) {
|
if (type) {
|
||||||
whereClause.type = type;
|
whereClause.type = type;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,19 @@
|
||||||
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 parsed = reorderRequestSchema.safeParse(await request.json());
|
const body = await request.json();
|
||||||
if (!parsed.success) {
|
const { items } = body;
|
||||||
|
|
||||||
|
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) =>
|
items.map((item: any) =>
|
||||||
prisma.quizSet.update({
|
prisma.quizSet.update({
|
||||||
where: { id: item.id },
|
where: { id: item.id },
|
||||||
data: {
|
data: {
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,16 @@
|
||||||
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 type = getInstructionType(request);
|
const { searchParams } = new URL(request.url);
|
||||||
if (!type) return NextResponse.json({ error: "Invalid instruction type" }, { status: 400 });
|
const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
|
||||||
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 type = getInstructionType(request);
|
const { searchParams } = new URL(request.url);
|
||||||
if (!type) return NextResponse.json({ error: "Invalid instruction type" }, { status: 400 });
|
const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
|
||||||
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") {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi";
|
|
||||||
import * as spacedRepetitionService from "@/services/spacedRepetitionService";
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
_request: NextRequest,
|
|
||||||
context: { params: Promise<{ id: string; deckId: string }> }
|
|
||||||
) {
|
|
||||||
const { id, deckId } = await context.params;
|
|
||||||
try {
|
|
||||||
await spacedRepetitionService.removeDeck(id, deckId);
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
return spacedRepetitionErrorResponse(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import {
|
|
||||||
addSpacedRepetitionDeckSchema,
|
|
||||||
reorderSpacedRepetitionDecksSchema,
|
|
||||||
} from "@/lib/validation/spacedRepetitionSchemas";
|
|
||||||
import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi";
|
|
||||||
import * as spacedRepetitionService from "@/services/spacedRepetitionService";
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
request: NextRequest,
|
|
||||||
context: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const { id } = await context.params;
|
|
||||||
const parsed = addSpacedRepetitionDeckSchema.safeParse(
|
|
||||||
await request.json().catch(() => null)
|
|
||||||
);
|
|
||||||
if (!parsed.success) return NextResponse.json({ error: "Invalid deck" }, { status: 400 });
|
|
||||||
try {
|
|
||||||
return NextResponse.json(
|
|
||||||
await spacedRepetitionService.addDeck(id, parsed.data.deckId),
|
|
||||||
{ status: 201 }
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
return spacedRepetitionErrorResponse(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PATCH(
|
|
||||||
request: NextRequest,
|
|
||||||
context: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const { id } = await context.params;
|
|
||||||
const parsed = reorderSpacedRepetitionDecksSchema.safeParse(
|
|
||||||
await request.json().catch(() => null)
|
|
||||||
);
|
|
||||||
if (!parsed.success) return NextResponse.json({ error: "Invalid deck order" }, { status: 400 });
|
|
||||||
try {
|
|
||||||
await spacedRepetitionService.reorderDecks(id, parsed.data.deckIds);
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
return spacedRepetitionErrorResponse(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { reviewSpacedRepetitionCardSchema } from "@/lib/validation/spacedRepetitionSchemas";
|
|
||||||
import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi";
|
|
||||||
import * as spacedRepetitionService from "@/services/spacedRepetitionService";
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
request: NextRequest,
|
|
||||||
context: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const { id } = await context.params;
|
|
||||||
const parsed = reviewSpacedRepetitionCardSchema.safeParse(
|
|
||||||
await request.json().catch(() => null)
|
|
||||||
);
|
|
||||||
if (!parsed.success) return NextResponse.json({ error: "Invalid review" }, { status: 400 });
|
|
||||||
try {
|
|
||||||
return NextResponse.json(
|
|
||||||
await spacedRepetitionService.reviewCard({ setId: id, ...parsed.data })
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
return spacedRepetitionErrorResponse(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { updateSpacedRepetitionSetSchema } from "@/lib/validation/spacedRepetitionSchemas";
|
|
||||||
import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi";
|
|
||||||
import * as spacedRepetitionService from "@/services/spacedRepetitionService";
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
_request: NextRequest,
|
|
||||||
context: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const { id } = await context.params;
|
|
||||||
try {
|
|
||||||
return NextResponse.json(await spacedRepetitionService.getSet(id));
|
|
||||||
} catch (error) {
|
|
||||||
return spacedRepetitionErrorResponse(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PATCH(
|
|
||||||
request: NextRequest,
|
|
||||||
context: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const { id } = await context.params;
|
|
||||||
const parsed = updateSpacedRepetitionSetSchema.safeParse(
|
|
||||||
await request.json().catch(() => null)
|
|
||||||
);
|
|
||||||
if (!parsed.success) return NextResponse.json({ error: "Invalid update" }, { status: 400 });
|
|
||||||
try {
|
|
||||||
return NextResponse.json(await spacedRepetitionService.updateSet(id, parsed.data));
|
|
||||||
} catch (error) {
|
|
||||||
return spacedRepetitionErrorResponse(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
_request: NextRequest,
|
|
||||||
context: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const { id } = await context.params;
|
|
||||||
try {
|
|
||||||
await spacedRepetitionService.deleteSet(id);
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
return spacedRepetitionErrorResponse(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi";
|
|
||||||
import * as spacedRepetitionService from "@/services/spacedRepetitionService";
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
_request: NextRequest,
|
|
||||||
context: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const { id } = await context.params;
|
|
||||||
try {
|
|
||||||
return NextResponse.json(await spacedRepetitionService.getStudyState(id));
|
|
||||||
} catch (error) {
|
|
||||||
return spacedRepetitionErrorResponse(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { createSpacedRepetitionSetSchema } from "@/lib/validation/spacedRepetitionSchemas";
|
|
||||||
import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi";
|
|
||||||
import * as spacedRepetitionService from "@/services/spacedRepetitionService";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const classId = new URL(request.url).searchParams.get("classId");
|
|
||||||
if (!classId) return NextResponse.json({ error: "classId is required" }, { status: 400 });
|
|
||||||
try {
|
|
||||||
return NextResponse.json(await spacedRepetitionService.listSetsByClass(classId));
|
|
||||||
} catch (error) {
|
|
||||||
return spacedRepetitionErrorResponse(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const parsed = createSpacedRepetitionSetSchema.safeParse(
|
|
||||||
await request.json().catch(() => null)
|
|
||||||
);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json({ error: "Invalid repetition set" }, { status: 400 });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return NextResponse.json(await spacedRepetitionService.createSet(parsed.data), {
|
|
||||||
status: 201,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return spacedRepetitionErrorResponse(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -170,537 +170,6 @@
|
||||||
.animate-slide-up { animation: slideUp .32s var(--ease-spring); }
|
.animate-slide-up { animation: slideUp .32s var(--ease-spring); }
|
||||||
.animate-subtle-pulse { animation: subtlePulse 1.7s ease-in-out infinite; }
|
.animate-subtle-pulse { animation: subtlePulse 1.7s ease-in-out infinite; }
|
||||||
|
|
||||||
/* Arcade routes take over the complete application shell, including navigation. */
|
|
||||||
body:has(.arcade-route) {
|
|
||||||
--theme-bg-base: #070b18;
|
|
||||||
--theme-bg-surface: #11182a;
|
|
||||||
--theme-bg-surface-alt: #19233a;
|
|
||||||
--theme-bg-callout: #282357;
|
|
||||||
--theme-primary: #9a8cff;
|
|
||||||
--theme-primary-hover: #b5aaff;
|
|
||||||
--theme-text-heading: #f9f7ff;
|
|
||||||
--theme-text-body: #dbe2f4;
|
|
||||||
--theme-text-secondary: #aab6d1;
|
|
||||||
--theme-text-muted: #71809e;
|
|
||||||
--theme-border: #34415c;
|
|
||||||
--theme-border-light: #242f46;
|
|
||||||
min-height: 100vh;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 76% 4%, rgba(116, 91, 255, .2), transparent 28rem),
|
|
||||||
radial-gradient(circle at 30% 92%, rgba(5, 198, 181, .11), transparent 34rem),
|
|
||||||
linear-gradient(145deg, #080c19, #0c1221 58%, #0b1020);
|
|
||||||
}
|
|
||||||
|
|
||||||
body:has(.arcade-route)::before {
|
|
||||||
content: "";
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: -1;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: .11;
|
|
||||||
background-image: radial-gradient(rgba(201, 211, 255, .7) .7px, transparent .7px);
|
|
||||||
background-size: 22px 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body:has(.arcade-route) main { min-height: 100vh; }
|
|
||||||
body:has(.arcade-route) .app-page { max-width: none; min-height: 100vh; }
|
|
||||||
body:has(.arcade-route) .arcade-route { width: min(100%, 1360px); margin-inline: auto; }
|
|
||||||
body:has(.arcade-route) .app-sidebar,
|
|
||||||
body:has(.arcade-route) .app-mobile-header {
|
|
||||||
border-color: rgba(138, 157, 201, .15);
|
|
||||||
background: rgba(8, 13, 27, .9);
|
|
||||||
box-shadow: 18px 0 55px rgba(0, 0, 0, .16);
|
|
||||||
}
|
|
||||||
|
|
||||||
body:has(.connections-world) {
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 28% 0%, rgba(88, 101, 242, .34), transparent 36rem),
|
|
||||||
radial-gradient(circle at 96% 28%, rgba(239, 71, 111, .16), transparent 30rem),
|
|
||||||
linear-gradient(145deg, #0b1329, #0e1932 54%, #142440);
|
|
||||||
}
|
|
||||||
|
|
||||||
.arcade-lobby-heading { position: relative; }
|
|
||||||
.arcade-lobby-heading::after {
|
|
||||||
content: "SELECT GAME";
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: .5rem;
|
|
||||||
color: rgba(154, 140, 255, .08);
|
|
||||||
font-size: clamp(3rem, 8vw, 7rem);
|
|
||||||
font-weight: 950;
|
|
||||||
letter-spacing: -.06em;
|
|
||||||
line-height: .8;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arcade-cabinet {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
min-height: 30rem;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid rgba(148, 163, 202, .2);
|
|
||||||
border-radius: 1.75rem;
|
|
||||||
background: #11182a;
|
|
||||||
box-shadow: inset 0 1px rgba(255,255,255,.06), 0 22px 55px rgba(0,0,0,.28);
|
|
||||||
transition: transform .24s var(--ease-spring), box-shadow .24s ease, border-color .24s ease;
|
|
||||||
}
|
|
||||||
.arcade-cabinet.is-playable:hover { transform: translateY(-7px) rotate(-.3deg); border-color: rgba(181,170,255,.65); box-shadow: 0 30px 70px rgba(0,0,0,.38), 0 0 45px rgba(126,103,255,.13); }
|
|
||||||
.arcade-cabinet.is-coming { filter: saturate(.82); }
|
|
||||||
.arcade-visual { position: relative; height: 13.5rem; overflow: hidden; border-bottom: 1px solid rgba(255,255,255,.1); }
|
|
||||||
.arcade-cabinet-copy { display: flex; flex: 1; flex-direction: column; padding: 1.25rem; }
|
|
||||||
.arcade-cabinet-status { font-size: .65rem; font-weight: 900; letter-spacing: .18em; text-transform: uppercase; }
|
|
||||||
.arcade-cabinet-copy h3 { margin-top: .35rem; color: white; font-size: 1.55rem; font-weight: 900; letter-spacing: -.04em; }
|
|
||||||
.arcade-cabinet-copy > p:not(.arcade-cabinet-status) { margin-top: .55rem; flex: 1; color: #aab6d1; font-size: .86rem; line-height: 1.65; }
|
|
||||||
.arcade-cabinet-action { display: grid; min-height: 2.9rem; margin-top: 1.1rem; place-items: center; border: 1px solid rgba(255,255,255,.13); border-radius: .9rem; color: #8d9ab5; background: rgba(5,9,20,.3); font-size: .8rem; font-weight: 850; }
|
|
||||||
.is-playable .arcade-cabinet-action { color: #14152a; border-color: transparent; background: linear-gradient(135deg, #a89dff, #8170f5); box-shadow: 0 10px 28px rgba(126,103,255,.28); }
|
|
||||||
|
|
||||||
.connections-visual { display: grid; place-items: center; background: radial-gradient(circle at 50% 20%, rgba(180,162,255,.35), transparent 55%), linear-gradient(145deg,#342c64,#1d2042); }
|
|
||||||
.connections-visual-grid { display: grid; width: 9.5rem; grid-template-columns: repeat(4, 1fr); gap: .38rem; transform: perspective(400px) rotateX(8deg) rotateZ(-2deg); }
|
|
||||||
.connections-visual-grid i { aspect-ratio: 1; border-radius: .5rem; box-shadow: inset 0 1px rgba(255,255,255,.45), 0 5px 10px rgba(0,0,0,.22); }
|
|
||||||
.connections-visual-grid i:nth-child(-n+4) { background: #ffd166; }
|
|
||||||
.connections-visual-grid i:nth-child(n+5):nth-child(-n+8) { background: #5ee0a0; }
|
|
||||||
.connections-visual-grid i:nth-child(n+9):nth-child(-n+12) { background: #5ac8fa; }
|
|
||||||
.connections-visual-grid i:nth-child(n+13) { background: #b69cff; }
|
|
||||||
.connections-visual-label { position: absolute; right: 1rem; bottom: .75rem; color: rgba(255,255,255,.6); font-size: .62rem; font-weight: 900; letter-spacing: .16em; text-transform: uppercase; }
|
|
||||||
|
|
||||||
.crossword-visual { display: grid; place-items: center; background: linear-gradient(150deg,#f4dca1,#c98c45); }
|
|
||||||
.crossword-visual::before { content: ""; position: absolute; inset: 0; opacity: .12; background-image: linear-gradient(#412911 1px,transparent 1px),linear-gradient(90deg,#412911 1px,transparent 1px); background-size: 18px 18px; }
|
|
||||||
.crossword-grid { display: grid; width: 10rem; grid-template-columns: repeat(8,1fr); gap: 2px; transform: rotate(-3deg); filter: drop-shadow(0 12px 12px rgba(71,38,10,.28)); }
|
|
||||||
.crossword-grid i { display: grid; aspect-ratio: 1; place-items: center; background: #312419; color: transparent; font-size: .62rem; font-style: normal; font-weight: 950; }
|
|
||||||
.crossword-grid i.has-letter { color: #322112; background: #fff9e9; }
|
|
||||||
.crossword-pencil { position: absolute; right: 1.2rem; bottom: .55rem; color: #6d3e17; font-size: 3rem; transform: rotate(-24deg); text-shadow: 0 5px 8px rgba(64,35,10,.2); }
|
|
||||||
.arcade-cabinet-crossword .arcade-cabinet-status { color: #f3bb62; }
|
|
||||||
|
|
||||||
.blocks-visual { background: linear-gradient(#071a20,#0a3436); }
|
|
||||||
.blocks-visual::before { content: ""; position: absolute; inset: 0; opacity: .18; background-image: linear-gradient(rgba(74,222,128,.45) 1px,transparent 1px),linear-gradient(90deg,rgba(74,222,128,.45) 1px,transparent 1px); background-size: 24px 24px; }
|
|
||||||
.blocks-piece { position: absolute; display: grid; gap: 3px; }
|
|
||||||
.blocks-piece i,.blocks-floor i { border: 1px solid rgba(255,255,255,.35); border-radius: 4px; box-shadow: inset 0 0 8px rgba(255,255,255,.25),0 0 13px currentColor; }
|
|
||||||
.blocks-piece-a { left: 32%; top: 18%; grid-template-columns: repeat(2,1.8rem); color: #38e6c5; animation: arcadeBlockHover 2s ease-in-out infinite; }
|
|
||||||
.blocks-piece-a i { width: 1.8rem; height: 1.8rem; background: #0fcbaa; }
|
|
||||||
.blocks-piece-b { right: 18%; top: 42%; grid-template-columns: repeat(3,1.55rem); color: #ff4f91; transform: rotate(90deg); }
|
|
||||||
.blocks-piece-b i { width: 1.55rem; height: 1.55rem; background: #ed397e; }
|
|
||||||
.blocks-floor { position: absolute; right: 12%; bottom: 1rem; left: 12%; display: grid; grid-template-columns: repeat(6,1fr); gap: 3px; }
|
|
||||||
.blocks-floor i { aspect-ratio: 1; color: #ffc857; background: #eaaa2e; }
|
|
||||||
.blocks-floor i:nth-child(3n) { color: #6c7cff; background: #5868ee; transform: translateY(-1.5rem); }
|
|
||||||
.blocks-arrow { position: absolute; left: 19%; top: 20%; color: rgba(103,232,211,.5); font-size: 2.2rem; animation: arcadeArrowDown 1.3s ease-in-out infinite; }
|
|
||||||
.arcade-cabinet-falling-blocks .arcade-cabinet-status { color: #49ddb6; }
|
|
||||||
|
|
||||||
.asteroid-visual { background: radial-gradient(circle at 72% 28%,#253c74,#101735 45%,#070b19); }
|
|
||||||
.arcade-star { position: absolute; width: 3px; height: 3px; border-radius: 50%; background: white; box-shadow: 0 0 8px white; }
|
|
||||||
.star-a { left: 16%; top: 18%; }.star-b { right: 18%; top: 12%; }.star-c { left: 48%; top: 39%; }
|
|
||||||
.asteroid { position: absolute; border: 2px solid #8c91a6; border-radius: 47% 53% 42% 58%; background: radial-gradient(circle at 32% 28%,#9299ac,#4c5268 62%,#292e41); box-shadow: inset -7px -8px 12px rgba(0,0,0,.35),0 8px 18px rgba(0,0,0,.35); }
|
|
||||||
.asteroid::after { content: ""; position: absolute; width: 28%; height: 24%; left: 18%; top: 22%; border-radius: 50%; background: rgba(32,37,55,.45); }
|
|
||||||
.asteroid-a { width: 3.2rem; height: 3rem; right: 14%; top: 18%; transform: rotate(18deg); }.asteroid-b { width: 2rem; height: 2rem; left: 16%; top: 27%; }.asteroid-c { width: 1.4rem; height: 1.35rem; right: 32%; bottom: 18%; }
|
|
||||||
.space-station { position: absolute; left: 20%; bottom: 18%; width: 4rem; height: 1.4rem; border-radius: 65% 20% 35% 65%; background: linear-gradient(90deg,#b9c8ef,#6f83bd); transform: rotate(-12deg); box-shadow: 0 0 20px rgba(105,151,255,.35); }
|
|
||||||
.space-station::before { content: ""; position: absolute; left: 1.2rem; bottom: 1rem; border-right: 1rem solid transparent; border-bottom: 1.5rem solid #53699f; border-left: .25rem solid transparent; }
|
|
||||||
.space-station i { position: absolute; right: -.75rem; top: .3rem; width: 1rem; height: .8rem; border-radius: 50%; background: #50e6ff; box-shadow: 0 0 16px #4fdff8; }
|
|
||||||
.laser-beam { position: absolute; width: 38%; height: 2px; left: 42%; top: 52%; background: linear-gradient(90deg,#70f4ff,transparent); transform: rotate(-20deg); transform-origin: left; box-shadow: 0 0 8px #52e7ff; }
|
|
||||||
.shield-ring { position: absolute; left: 8%; bottom: 4%; width: 7.5rem; height: 6rem; border: 2px solid rgba(90,221,255,.25); border-radius: 50%; transform: rotate(-15deg); }
|
|
||||||
.arcade-cabinet-asteroid-defense .arcade-cabinet-status { color: #65c9ff; }
|
|
||||||
|
|
||||||
@keyframes arcadeBlockHover { 0%,100% { transform: translateY(0); } 50% { transform: translateY(8px); } }
|
|
||||||
@keyframes arcadeArrowDown { 0%,100% { opacity: .3; transform: translateY(-5px); } 50% { opacity: .8; transform: translateY(6px); } }
|
|
||||||
|
|
||||||
/* Connections has its own playful game-show visual system, independent of the desk UI. */
|
|
||||||
.connections-world {
|
|
||||||
--theme-bg-surface: #182443;
|
|
||||||
--theme-bg-surface-alt: #223154;
|
|
||||||
--theme-bg-callout: #2c3d68;
|
|
||||||
--theme-primary: #ffd166;
|
|
||||||
--theme-primary-hover: #ffe29a;
|
|
||||||
--theme-text-heading: #f8fbff;
|
|
||||||
--theme-text-body: #dbe7ff;
|
|
||||||
--theme-text-secondary: #afc0df;
|
|
||||||
--theme-text-muted: #8497bd;
|
|
||||||
--theme-border: #52668d;
|
|
||||||
--theme-border-light: #33486f;
|
|
||||||
position: relative;
|
|
||||||
isolation: isolate;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--theme-text-body);
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 10% 0%, rgba(88, 101, 242, .45), transparent 28rem),
|
|
||||||
radial-gradient(circle at 95% 25%, rgba(239, 71, 111, .2), transparent 25rem),
|
|
||||||
linear-gradient(145deg, #101a35 0%, #111d39 48%, #172846 100%);
|
|
||||||
box-shadow: 0 28px 80px rgba(8, 15, 34, .28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.connections-hub.connections-world,
|
|
||||||
.connections-stage.connections-world {
|
|
||||||
overflow: visible;
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.connections-hub.connections-world::before,
|
|
||||||
.connections-stage.connections-world::before { display: none; }
|
|
||||||
|
|
||||||
.connections-world::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: -1;
|
|
||||||
opacity: .13;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(rgba(255,255,255,.18) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(255,255,255,.18) 1px, transparent 1px);
|
|
||||||
background-size: 42px 42px;
|
|
||||||
mask-image: linear-gradient(to bottom, black, transparent 78%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.connections-world .editorial-title {
|
|
||||||
font-family: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
|
|
||||||
font-weight: 850;
|
|
||||||
letter-spacing: -.045em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connections-hub-hero,
|
|
||||||
.connections-topbar,
|
|
||||||
.connections-status,
|
|
||||||
.connections-setup,
|
|
||||||
.connections-import {
|
|
||||||
backdrop-filter: blur(18px);
|
|
||||||
box-shadow: inset 0 1px rgba(255,255,255,.08), 0 18px 45px rgba(2, 8, 23, .22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.connections-hub-hero {
|
|
||||||
background: linear-gradient(120deg, rgba(34,49,84,.94), rgba(24,36,67,.72));
|
|
||||||
border: 1px solid rgba(151, 174, 221, .18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.connections-pack-card {
|
|
||||||
border-color: rgba(117, 141, 187, .28);
|
|
||||||
background: rgba(24, 36, 67, .74);
|
|
||||||
box-shadow: inset 0 1px rgba(255,255,255,.05), 0 12px 30px rgba(3, 9, 24, .16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.connections-pack-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
border-color: rgba(255, 209, 102, .48);
|
|
||||||
}
|
|
||||||
|
|
||||||
.connections-pack-card.is-active {
|
|
||||||
border-color: #ffd166;
|
|
||||||
background: linear-gradient(120deg, rgba(65, 72, 132, .9), rgba(38, 55, 96, .9));
|
|
||||||
box-shadow: 0 0 0 1px rgba(255,209,102,.25), 0 18px 45px rgba(3,9,24,.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.connections-primary-button {
|
|
||||||
color: #172033;
|
|
||||||
background: linear-gradient(135deg, #ffd166, #ffb84d);
|
|
||||||
box-shadow: 0 10px 26px rgba(255, 184, 77, .24), inset 0 1px rgba(255,255,255,.5);
|
|
||||||
transition: transform .18s var(--ease-spring), filter .18s ease, box-shadow .18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connections-primary-button:not(:disabled):hover {
|
|
||||||
transform: translateY(-2px) scale(1.01);
|
|
||||||
filter: brightness(1.06);
|
|
||||||
box-shadow: 0 14px 34px rgba(255, 184, 77, .32), inset 0 1px rgba(255,255,255,.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.connections-secondary-button {
|
|
||||||
color: #dbe7ff;
|
|
||||||
border: 1px solid #52668d;
|
|
||||||
background: rgba(24, 36, 67, .82);
|
|
||||||
transition: transform .18s var(--ease-spring), background .18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connections-secondary-button:not(:disabled):hover { transform: translateY(-2px); background: #2c3d68; }
|
|
||||||
|
|
||||||
.connections-tile {
|
|
||||||
color: #15213a;
|
|
||||||
border: 1px solid rgba(255,255,255,.75);
|
|
||||||
background: linear-gradient(145deg, #fffaf0, #e9effa);
|
|
||||||
box-shadow: 0 7px 0 #aebbd2, 0 12px 24px rgba(2,8,23,.24), inset 0 1px #fff;
|
|
||||||
animation: connectionsTileIn .36s var(--ease-spring) both;
|
|
||||||
transition: transform .16s var(--ease-spring), box-shadow .16s ease, color .16s ease, background .16s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connections-tile:hover { transform: translateY(-3px); box-shadow: 0 9px 0 #aebbd2, 0 16px 30px rgba(2,8,23,.28), inset 0 1px #fff; }
|
|
||||||
.connections-tile.is-selected {
|
|
||||||
color: #fff;
|
|
||||||
border-color: #8c7cf7;
|
|
||||||
background: linear-gradient(145deg, #735df2, #5540ca);
|
|
||||||
box-shadow: 0 5px 0 #30228e, 0 12px 28px rgba(84,64,202,.4), inset 0 1px rgba(255,255,255,.28);
|
|
||||||
transform: translateY(2px) scale(.975);
|
|
||||||
animation: connectionsTileSelect .25s var(--ease-spring);
|
|
||||||
}
|
|
||||||
|
|
||||||
.connections-group,
|
|
||||||
.connections-review-card {
|
|
||||||
color: #172033;
|
|
||||||
box-shadow: inset 0 1px rgba(255,255,255,.55), 0 10px 26px rgba(2,8,23,.2);
|
|
||||||
}
|
|
||||||
.connections-group .text-text-heading,
|
|
||||||
.connections-group .text-text-secondary,
|
|
||||||
.connections-review-card .text-text-heading,
|
|
||||||
.connections-review-card .text-text-secondary { color: #172033; }
|
|
||||||
.connections-group { animation: connectionsGroupReveal .56s var(--ease-spring) both; }
|
|
||||||
.connections-group-0 { background: linear-gradient(135deg, #ffd166, #f7b944); }
|
|
||||||
.connections-group-1 { background: linear-gradient(135deg, #70e1a1, #36bd7c); }
|
|
||||||
.connections-group-2 { background: linear-gradient(135deg, #72d5f7, #4aa8e8); }
|
|
||||||
.connections-group-3 { background: linear-gradient(135deg, #b9a2ff, #8d73ed); }
|
|
||||||
|
|
||||||
.connections-results-hero {
|
|
||||||
background: linear-gradient(130deg, #5b46d8, #283d77 58%, #164e63);
|
|
||||||
box-shadow: inset 0 1px rgba(255,255,255,.18), 0 22px 50px rgba(2,8,23,.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.connections-completion {
|
|
||||||
position: relative;
|
|
||||||
display: grid;
|
|
||||||
min-height: 32rem;
|
|
||||||
place-content: center;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 1.75rem;
|
|
||||||
text-align: center;
|
|
||||||
background: radial-gradient(circle, rgba(94,234,212,.2), transparent 42%), rgba(10,18,39,.72);
|
|
||||||
animation: connectionsCompletionIn .55s var(--ease-spring) both;
|
|
||||||
}
|
|
||||||
.connections-completion-mark {
|
|
||||||
display: grid;
|
|
||||||
width: 6rem;
|
|
||||||
height: 6rem;
|
|
||||||
margin: 0 auto 1.5rem;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: 999px;
|
|
||||||
color: #172033;
|
|
||||||
background: #ffd166;
|
|
||||||
box-shadow: 0 0 0 12px rgba(255,209,102,.12), 0 0 65px rgba(255,209,102,.46);
|
|
||||||
font-size: 3rem;
|
|
||||||
font-weight: 900;
|
|
||||||
animation: connectionsWinMark .8s .15s var(--ease-spring) both;
|
|
||||||
}
|
|
||||||
.connections-completion p { color: #8ee7d1; font-size: .75rem; font-weight: 850; letter-spacing: .2em; text-transform: uppercase; }
|
|
||||||
.connections-completion h2 { margin-top: .35rem; color: white; font-size: clamp(2.5rem, 8vw, 5.5rem); font-weight: 900; letter-spacing: -.065em; line-height: .95; }
|
|
||||||
.connections-completion span { margin-top: 1rem; color: #b9c8e5; font-weight: 700; }
|
|
||||||
|
|
||||||
.connections-confetti { position: absolute; inset: 0; pointer-events: none; }
|
|
||||||
.connections-confetti i {
|
|
||||||
position: absolute;
|
|
||||||
top: -8%;
|
|
||||||
left: var(--confetti-x);
|
|
||||||
width: 9px;
|
|
||||||
height: 17px;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: var(--confetti-color);
|
|
||||||
animation: connectionsConfettiFall 1.45s var(--confetti-delay) cubic-bezier(.2,.7,.3,1) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes connectionsTileIn { from { opacity: 0; transform: translateY(16px) scale(.94); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
|
||||||
@keyframes connectionsTileSelect { 0% { transform: scale(1); } 55% { transform: translateY(3px) scale(.94); } 100% { transform: translateY(2px) scale(.975); } }
|
|
||||||
@keyframes connectionsShake { 0%,100% { transform: translateX(0); } 20% { transform: translateX(-9px); } 40% { transform: translateX(8px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(3px); } }
|
|
||||||
@keyframes connectionsGroupReveal { from { opacity: 0; transform: scale(.9) translateY(18px); filter: brightness(1.6); } 65% { transform: scale(1.025) translateY(-2px); } to { opacity: 1; transform: scale(1) translateY(0); filter: brightness(1); } }
|
|
||||||
@keyframes connectionsCompletionIn { from { opacity: 0; transform: scale(.94); } to { opacity: 1; transform: scale(1); } }
|
|
||||||
@keyframes connectionsWinMark { from { opacity: 0; transform: scale(.2) rotate(-25deg); } 70% { transform: scale(1.12) rotate(4deg); } to { opacity: 1; transform: scale(1) rotate(0); } }
|
|
||||||
@keyframes connectionsConfettiFall { 0% { opacity: 0; transform: translateY(-1rem) rotate(0); } 10% { opacity: 1; } 100% { opacity: 1; transform: translateY(38rem) rotate(var(--confetti-spin)); } }
|
|
||||||
@keyframes connectionsResultsIn { from { opacity: 0; transform: translateY(22px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
|
||||||
.animate-connections-shake { animation: connectionsShake .42s ease-in-out; }
|
|
||||||
.animate-connections-results-in { animation: connectionsResultsIn .52s var(--ease-spring) both; }
|
|
||||||
|
|
||||||
/* Crossword turns the full arcade shell into a warm, ink-and-paper newsroom. */
|
|
||||||
body:has(.crossword-world) {
|
|
||||||
--theme-bg-base: #20170f;
|
|
||||||
--theme-bg-surface: #f6ecd2;
|
|
||||||
--theme-bg-surface-alt: #ead9b5;
|
|
||||||
--theme-bg-callout: #dfc795;
|
|
||||||
--theme-primary: #9a5a24;
|
|
||||||
--theme-primary-hover: #7b431b;
|
|
||||||
--theme-text-heading: #2d2117;
|
|
||||||
--theme-text-body: #493725;
|
|
||||||
--theme-text-secondary: #68523a;
|
|
||||||
--theme-text-muted: #8a7052;
|
|
||||||
--theme-border: #a9865d;
|
|
||||||
--theme-border-light: #cfb88e;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 18% 8%, rgba(230, 181, 88, .18), transparent 28rem),
|
|
||||||
linear-gradient(135deg, #1d140d, #3c2818 55%, #21160e);
|
|
||||||
}
|
|
||||||
body:has(.crossword-world)::before {
|
|
||||||
opacity: .16;
|
|
||||||
background-image: repeating-linear-gradient(0deg, transparent 0 3px, rgba(255,236,195,.16) 4px);
|
|
||||||
}
|
|
||||||
body:has(.crossword-world) .app-sidebar,
|
|
||||||
body:has(.crossword-world) .app-mobile-header {
|
|
||||||
--theme-bg-surface-alt: #3a2819;
|
|
||||||
--theme-bg-callout: #e4cc98;
|
|
||||||
--theme-primary: #8a4f20;
|
|
||||||
--theme-text-heading: #fff3d8;
|
|
||||||
--theme-text-secondary: #d7c3a2;
|
|
||||||
--theme-text-muted: #aa9170;
|
|
||||||
--theme-border-light: #5b4028;
|
|
||||||
color: #f8ead0;
|
|
||||||
border-color: rgba(224, 190, 126, .22);
|
|
||||||
background: rgba(35, 23, 14, .94);
|
|
||||||
}
|
|
||||||
|
|
||||||
.crossword-world {
|
|
||||||
position: relative;
|
|
||||||
color: var(--theme-text-body);
|
|
||||||
}
|
|
||||||
.crossword-hub.crossword-world,
|
|
||||||
.crossword-stage.crossword-world { overflow: visible; }
|
|
||||||
.crossword-hero,
|
|
||||||
.crossword-setup,
|
|
||||||
.crossword-active-clue,
|
|
||||||
.crossword-clues,
|
|
||||||
.crossword-import,
|
|
||||||
.crossword-paper {
|
|
||||||
border: 1px solid rgba(100, 68, 35, .24);
|
|
||||||
background:
|
|
||||||
linear-gradient(rgba(255,255,255,.18), rgba(255,255,255,0)),
|
|
||||||
repeating-linear-gradient(0deg, rgba(83,54,24,.025) 0 1px, transparent 1px 4px),
|
|
||||||
#f4e7c9;
|
|
||||||
box-shadow: inset 0 1px rgba(255,255,255,.7), 0 18px 48px rgba(15,9,4,.25);
|
|
||||||
}
|
|
||||||
.crossword-hero { position: relative; overflow: hidden; }
|
|
||||||
.crossword-hero > * { position: relative; z-index: 1; }
|
|
||||||
.crossword-hero::after {
|
|
||||||
content: "DAILY STUDY";
|
|
||||||
position: absolute;
|
|
||||||
right: 1rem;
|
|
||||||
top: .35rem;
|
|
||||||
color: rgba(77,51,28,.07);
|
|
||||||
font-family: Georgia, serif;
|
|
||||||
font-size: clamp(2.75rem, 5vw, 5rem);
|
|
||||||
font-weight: 900;
|
|
||||||
letter-spacing: -.06em;
|
|
||||||
line-height: 1;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.crossword-kicker,
|
|
||||||
.crossword-section-label {
|
|
||||||
color: #925623;
|
|
||||||
font-size: .68rem;
|
|
||||||
font-weight: 900;
|
|
||||||
letter-spacing: .18em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.crossword-section-label { display: block; margin-bottom: .7rem; color: var(--theme-text-muted); }
|
|
||||||
.crossword-primary {
|
|
||||||
color: #fff8e7;
|
|
||||||
background: linear-gradient(135deg, #a86429, #744018);
|
|
||||||
box-shadow: inset 0 1px rgba(255,255,255,.24), 0 7px 0 #4a2912, 0 12px 24px rgba(49,27,12,.22);
|
|
||||||
transition: transform .16s var(--ease-spring), filter .16s ease;
|
|
||||||
}
|
|
||||||
.crossword-primary:hover { transform: translateY(-2px); filter: brightness(1.08); }
|
|
||||||
.crossword-primary:active { transform: translateY(4px); box-shadow: inset 0 1px rgba(255,255,255,.2), 0 3px 0 #4a2912; }
|
|
||||||
.crossword-pack {
|
|
||||||
border-color: rgba(111,76,43,.25);
|
|
||||||
background: rgba(244,231,201,.92);
|
|
||||||
box-shadow: 0 10px 26px rgba(20,12,6,.16), inset 0 1px rgba(255,255,255,.65);
|
|
||||||
transition: transform .18s ease, border-color .18s ease;
|
|
||||||
}
|
|
||||||
.crossword-pack:hover { transform: translateY(-2px); border-color: #a86429; }
|
|
||||||
.crossword-pack.is-active { border-color: #8d511f; box-shadow: 0 0 0 2px rgba(168,100,41,.2), 0 14px 30px rgba(20,12,6,.2); }
|
|
||||||
.crossword-paper > div i { aspect-ratio: 1; border: 1px solid rgba(45,33,23,.3); }
|
|
||||||
.crossword-size {
|
|
||||||
color: var(--theme-text-secondary);
|
|
||||||
border: 1px solid var(--theme-border-light);
|
|
||||||
background: rgba(255,250,235,.48);
|
|
||||||
}
|
|
||||||
.crossword-size.is-active { color: #2d2117; border-color: #955720; background: #e4c993; box-shadow: inset 0 0 0 2px rgba(255,255,255,.35); }
|
|
||||||
.crossword-board-preview { overflow: hidden; border: 1px solid #c9aa78; border-radius: .9rem; background: rgba(255,250,235,.48); }
|
|
||||||
.crossword-preview-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .65rem .8rem; border-bottom: 1px solid #d5bd93; color: #5d4630; }
|
|
||||||
.crossword-preview-heading span { font-size: .68rem; font-weight: 900; letter-spacing: .14em; text-transform: uppercase; }
|
|
||||||
.crossword-preview-heading small { color: #8a7052; font-size: .68rem; }
|
|
||||||
.crossword-preview-canvas { display: grid; min-height: 9rem; place-items: center; overflow: hidden; padding: .75rem; background: #322116; }
|
|
||||||
.crossword-preview-grid { display: grid; filter: drop-shadow(0 6px 9px rgba(0,0,0,.35)); }
|
|
||||||
.crossword-preview-grid i { border: .5px solid #9e8058; background: #fff6de; }
|
|
||||||
.crossword-preview-loading,.crossword-preview-canvas > p { color: #d7c3a2; font-size: .75rem; font-weight: 800; }
|
|
||||||
.crossword-option { display: flex; align-items: flex-start; gap: .7rem; padding: .75rem; border-radius: .75rem; background: rgba(224,202,157,.38); }
|
|
||||||
.crossword-option input { margin-top: .2rem; accent-color: #925623; }
|
|
||||||
.crossword-option strong,.crossword-option small { display: block; }
|
|
||||||
.crossword-option small { margin-top: .12rem; color: var(--theme-text-muted); }
|
|
||||||
.crossword-active-clue { display: grid; gap: .2rem; }
|
|
||||||
.crossword-active-clue span { color: #925623; font-size: .68rem; font-weight: 900; letter-spacing: .15em; text-transform: uppercase; }
|
|
||||||
.crossword-active-clue strong { color: #2d2117; font-family: Georgia, serif; font-size: 1.05rem; }
|
|
||||||
.crossword-active-clue small { color: var(--theme-text-muted); }
|
|
||||||
.crossword-toolbar { color: #f4e7c9; background: rgba(35,23,14,.9); box-shadow: 0 8px 24px rgba(13,8,4,.24); }
|
|
||||||
.crossword-toolbar > div { display: flex; align-items: center; gap: .35rem; }
|
|
||||||
.crossword-toolbar button { display: grid; width: 2rem; height: 2rem; place-items: center; border: 1px solid rgba(245,222,179,.25); border-radius: .45rem; font-weight: 900; }
|
|
||||||
.crossword-toolbar span { min-width: 3rem; text-align: center; font-size: .75rem; font-weight: 800; }
|
|
||||||
.crossword-board-viewport {
|
|
||||||
max-height: min(68vh, 46rem);
|
|
||||||
overflow: auto;
|
|
||||||
padding: 1rem;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
background: #2a1d13;
|
|
||||||
box-shadow: inset 0 0 0 5px #4c321e, inset 0 0 28px rgba(0,0,0,.7), 0 16px 38px rgba(13,8,4,.3);
|
|
||||||
}
|
|
||||||
.crossword-board { display: grid; width: max-content; min-width: 100%; min-height: 20rem; place-content: center; }
|
|
||||||
.crossword-cell {
|
|
||||||
position: relative;
|
|
||||||
display: grid;
|
|
||||||
width: var(--crossword-cell);
|
|
||||||
height: var(--crossword-cell);
|
|
||||||
place-items: center;
|
|
||||||
color: #25190f;
|
|
||||||
border: 1px solid #6f5335;
|
|
||||||
background: #fff8e6;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
||||||
transition: background .12s ease, box-shadow .12s ease;
|
|
||||||
}
|
|
||||||
.crossword-cell small { position: absolute; left: 2px; top: 0; font-size: max(7px, calc(var(--crossword-cell) * .22)); line-height: 1; }
|
|
||||||
.crossword-cell strong { font-size: max(12px, calc(var(--crossword-cell) * .56)); }
|
|
||||||
.crossword-cell.is-word { background: #f2d99f; }
|
|
||||||
.crossword-cell.is-active { z-index: 2; background: #ffe27e; box-shadow: inset 0 0 0 3px #925623; }
|
|
||||||
.crossword-cell.is-wrong::after { content: ""; position: absolute; right: 3px; bottom: 3px; width: 7px; height: 7px; border: 2px solid #a62e25; border-radius: 50%; }
|
|
||||||
.crossword-cell.is-revealed strong { color: #8a5a24; text-decoration: underline dotted; }
|
|
||||||
.crossword-secondary {
|
|
||||||
min-height: 2.75rem;
|
|
||||||
padding: .6rem .9rem;
|
|
||||||
border: 1px solid #9f7a50;
|
|
||||||
border-radius: .7rem;
|
|
||||||
color: #392718;
|
|
||||||
background: #ead6ae;
|
|
||||||
font-size: .78rem;
|
|
||||||
font-weight: 850;
|
|
||||||
box-shadow: 0 3px 0 #aa895f;
|
|
||||||
}
|
|
||||||
.crossword-secondary:hover { background: #f5e5c4; }
|
|
||||||
.crossword-clues { max-height: 72vh; overflow: hidden; }
|
|
||||||
.crossword-clues > div button { min-height: 2.5rem; border-bottom: 2px solid transparent; color: var(--theme-text-muted); font-size: .75rem; font-weight: 900; text-transform: uppercase; }
|
|
||||||
.crossword-clues > div button.is-active { color: #7d461d; border-color: #9a5a24; }
|
|
||||||
.crossword-clues ol { max-height: calc(72vh - 5rem); overflow-y: auto; padding-right: .25rem; }
|
|
||||||
.crossword-clues li button { display: grid; width: 100%; grid-template-columns: 2rem 1fr; gap: .4rem; padding: .55rem; border-radius: .5rem; text-align: left; color: #4b3826; }
|
|
||||||
.crossword-clues li button:hover,.crossword-clues li button.is-active { background: #dec38c; }
|
|
||||||
.crossword-clues li b { color: #8a4f20; }
|
|
||||||
.crossword-mobile-input { position: fixed; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
|
||||||
.crossword-results-hero { color: #f8ead0; background: linear-gradient(135deg, #3b2819, #6d421f); box-shadow: 0 18px 45px rgba(15,9,4,.3); }
|
|
||||||
.crossword-results-hero > div > div { background: rgba(255,244,220,.1); }
|
|
||||||
.crossword-results-hero small,.crossword-results-hero strong { display: block; }
|
|
||||||
.crossword-results-hero small { color: rgba(255,244,220,.6); font-size: .65rem; font-weight: 900; text-transform: uppercase; }
|
|
||||||
.crossword-results-hero strong { margin-top: .2rem; font-size: 1.25rem; }
|
|
||||||
.crossword-final-board { border: 1px solid #c8aa79; color: #392718; background: #f4e7c9; box-shadow: 0 12px 32px rgba(20,12,6,.18); }
|
|
||||||
.crossword-final-legend { display: flex; flex-wrap: wrap; gap: .8rem; color: #6a5139; font-size: .72rem; font-weight: 800; }
|
|
||||||
.crossword-final-legend span { display: inline-flex; align-items: center; gap: .35rem; }
|
|
||||||
.crossword-final-legend i { width: .8rem; height: .8rem; border: 1px solid currentColor; border-radius: .15rem; }
|
|
||||||
.crossword-final-legend i.is-correct { color: #356b3b; background: #bfe0b9; }
|
|
||||||
.crossword-final-legend i.is-assisted { color: #8a631c; background: #f2d47e; }
|
|
||||||
.crossword-final-legend i.is-incorrect { color: #923a30; background: #efb9af; }
|
|
||||||
.crossword-final-viewport { overflow: auto; padding: 1rem; border-radius: 1rem; background: #2a1d13; box-shadow: inset 0 0 22px rgba(0,0,0,.55); }
|
|
||||||
.crossword-final-grid { display: grid; width: max-content; min-width: 100%; place-content: center; }
|
|
||||||
.crossword-final-cell { position: relative; display: grid; place-items: center; color: #25190f; border: 1px solid #62482f; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
||||||
.crossword-final-cell.is-correct { background: #c7e6bf; box-shadow: inset 0 0 0 1px rgba(47,108,58,.35); }
|
|
||||||
.crossword-final-cell.is-assisted { background: #f2d47e; box-shadow: inset 0 0 0 2px rgba(138,99,28,.42); }
|
|
||||||
.crossword-final-cell.is-incorrect { background: #efbbb1; box-shadow: inset 0 0 0 2px rgba(145,47,39,.45); }
|
|
||||||
.crossword-final-cell small { position: absolute; left: 2px; top: 1px; font-size: 7px; line-height: 1; }
|
|
||||||
.crossword-final-cell strong { font-size: clamp(10px, 2vw, 15px); }
|
|
||||||
.crossword-review { border: 1px solid #c8aa79; color: #392718; background: #f4e7c9; box-shadow: 0 8px 22px rgba(20,12,6,.15); }
|
|
||||||
.crossword-review.is-incorrect { border-left: 6px solid #a63a2e; }
|
|
||||||
.crossword-review.is-correct { border-left: 6px solid #4f7a4f; }
|
|
||||||
.crossword-review.is-assisted { border-left: 6px solid #b58627; }
|
|
||||||
.crossword-review.is-omitted { opacity: .75; border-left: 6px solid #8c765a; }
|
|
||||||
|
|
||||||
@media (max-width: 639px) {
|
|
||||||
.crossword-board-viewport { margin-inline: -.25rem; padding: .6rem; }
|
|
||||||
.crossword-board { place-content: start; min-height: 24rem; }
|
|
||||||
.crossword-clues { max-height: 28rem; }
|
|
||||||
.crossword-hero::after { top: .75rem; right: .75rem; font-size: 2.75rem; }
|
|
||||||
.crossword-final-viewport { margin-inline: -.25rem; padding: .65rem; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-content h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: .5rem; color: var(--color-text-heading); }
|
.markdown-content h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: .5rem; color: var(--color-text-heading); }
|
||||||
.markdown-content h2 { font-size: 1.25rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }
|
.markdown-content h2 { font-size: 1.25rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }
|
||||||
.markdown-content h3 { font-size: 1.125rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }
|
.markdown-content h3 { font-size: 1.125rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Manrope, Newsreader } from "next/font/google";
|
import { Manrope, Newsreader } from "next/font/google";
|
||||||
|
import Script from "next/script";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const manrope = Manrope({
|
const manrope = Manrope({
|
||||||
|
|
@ -25,12 +26,10 @@ export default function RootLayout({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${manrope.variable} ${newsreader.variable} h-full`} data-scroll-behavior="smooth" suppressHydrationWarning>
|
<html lang="en" className={`${manrope.variable} ${newsreader.variable} h-full`} suppressHydrationWarning>
|
||||||
<head>
|
<body className="min-h-full flex flex-col font-sans antialiased">
|
||||||
<script
|
<Script id="theme-init" strategy="beforeInteractive">
|
||||||
id="theme-init"
|
{`
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `
|
|
||||||
try {
|
try {
|
||||||
const savedTheme = localStorage.theme
|
const savedTheme = localStorage.theme
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
|
@ -40,11 +39,8 @@ export default function RootLayout({
|
||||||
document.documentElement.classList.remove('dark')
|
document.documentElement.classList.remove('dark')
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
`,
|
`}
|
||||||
}}
|
</Script>
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body className="min-h-full flex flex-col font-sans antialiased" suppressHydrationWarning>
|
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,333 +1,158 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
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 [setupRequired, setSetupRequired] = useState<boolean | null>(null);
|
const [isSetup, setIsSetup] = useState<boolean | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/auth/setup-status")
|
fetch("/api/auth/setup-status")
|
||||||
.then((response) => response.json())
|
.then((res) => res.json())
|
||||||
.then((data) => setSetupRequired(data.setupRequired))
|
.then((data) => setIsSetup(data.setupRequired))
|
||||||
.catch(() => setSetupRequired(false));
|
.catch(() => setIsSetup(false)); // fallback
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function run(action: () => Promise<void>) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
setError("");
|
setError("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await action();
|
const res = await fetch("/api/auth/login", {
|
||||||
} catch (caught) {
|
method: "POST",
|
||||||
setError(caught instanceof Error ? caught.message : "Network error. Please try again.");
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || "Login failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = "/";
|
||||||
|
} catch {
|
||||||
|
setError("Network error. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogin(event: React.FormEvent) {
|
if (isSetup === null) {
|
||||||
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="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
<div className="animate-pulse flex items-center justify-center">
|
||||||
|
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</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="relative flex min-h-screen items-center justify-center overflow-hidden p-4 sm:p-8">
|
<div className="relative flex min-h-screen items-center justify-center overflow-hidden p-4 sm:p-8">
|
||||||
<div className="absolute inset-0 paper-grid opacity-70" />
|
<div className="absolute inset-0 paper-grid opacity-70" />
|
||||||
<div className="absolute -left-24 top-1/4 h-80 w-80 rounded-full bg-primary/15 blur-3xl" />
|
<div className="absolute -left-24 top-1/4 h-80 w-80 rounded-full bg-primary/15 blur-3xl" />
|
||||||
<div className="absolute -right-24 bottom-0 h-72 w-72 rounded-full bg-accent/15 blur-3xl" />
|
<div className="absolute -right-24 bottom-0 h-72 w-72 rounded-full bg-accent/15 blur-3xl" />
|
||||||
<div className="absolute right-4 top-4 z-10"><ThemeToggle /></div>
|
<div className="absolute right-4 top-4 z-10"><ThemeToggle /></div>
|
||||||
|
<div className="relative w-full max-w-md animate-fade-in">
|
||||||
<main className="relative w-full max-w-md animate-fade-in">
|
{/* Logo / Title area */}
|
||||||
<header className="mb-7 text-center">
|
<div className="mb-7 text-center">
|
||||||
<div className="mb-4 inline-grid h-14 w-14 place-items-center rounded-2xl bg-primary text-xl font-black text-white shadow-[0_12px_30px_color-mix(in_srgb,var(--theme-primary)_30%,transparent)]">S</div>
|
<div className="mb-4 inline-grid h-14 w-14 place-items-center rounded-2xl bg-primary text-xl font-black text-white shadow-[0_12px_30px_color-mix(in_srgb,var(--theme-primary)_30%,transparent)]">S</div>
|
||||||
<p className="text-xs font-bold uppercase tracking-[0.2em] text-primary">Personal workspace</p>
|
<p className="text-xs font-bold uppercase tracking-[0.2em] text-primary">Personal workspace</p>
|
||||||
<h1 className="editorial-title mt-2 text-4xl text-text-heading">{title}</h1>
|
<h1 className="editorial-title mt-2 text-4xl text-text-heading">
|
||||||
<p className="mt-1 text-sm text-text-muted">{description}</p>
|
{isSetup ? "Welcome to Study Desk" : "Welcome back"}
|
||||||
</header>
|
</h1>
|
||||||
|
<p className="text-text-muted mt-1 text-sm">
|
||||||
<div className="rounded-[2rem] border border-border-light bg-bg-surface/90 p-6 shadow-[var(--shadow-modal)] backdrop-blur sm:p-8">
|
{isSetup
|
||||||
{stage === "login" && (
|
? "Please set your admin password for the first time."
|
||||||
<form onSubmit={handleLogin} className="space-y-4">
|
: "Open your notes, decks, and practice quizzes."}
|
||||||
<PasswordField
|
</p>
|
||||||
id="password"
|
|
||||||
label="Password"
|
|
||||||
value={password}
|
|
||||||
onChange={setPassword}
|
|
||||||
placeholder={setupRequired ? "Create a new password" : "Enter your password"}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<ErrorMessage message={error} />
|
|
||||||
<PrimaryButton loading={loading} disabled={password.length < 8}>
|
|
||||||
{setupRequired ? "Save Password & Login" : "Sign In"}
|
|
||||||
</PrimaryButton>
|
|
||||||
{!setupRequired && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={beginReset}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full rounded-lg py-2 text-sm font-semibold text-primary hover:text-primary-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Forgot password?
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{stage === "token" && (
|
|
||||||
<form onSubmit={handleToken} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="reset-token" className="mb-1.5 block text-sm font-medium text-text-secondary">Reset token</label>
|
|
||||||
<input
|
|
||||||
id="reset-token"
|
|
||||||
value={token}
|
|
||||||
onChange={(event) => setToken(event.target.value)}
|
|
||||||
autoComplete="one-time-code"
|
|
||||||
autoFocus
|
|
||||||
className="min-h-12 w-full rounded-xl border border-border bg-bg-surface-alt/60 px-4 font-mono text-text-heading placeholder:text-text-muted focus:border-primary"
|
|
||||||
placeholder="Paste the token from Docker logs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ErrorMessage message={error} />
|
|
||||||
<PrimaryButton loading={loading} disabled={!token.trim()}>Validate Token</PrimaryButton>
|
|
||||||
<SecondaryButton onClick={returnToLogin}>Back to sign in</SecondaryButton>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{stage === "password" && (
|
|
||||||
<form onSubmit={handlePasswordReset} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<PasswordField
|
|
||||||
id="new-password"
|
|
||||||
label="New password"
|
|
||||||
value={password}
|
|
||||||
onChange={setPassword}
|
|
||||||
describedBy="password-length-status"
|
|
||||||
invalid={password.length < 8}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<ValidationMessage
|
|
||||||
id="password-length-status"
|
|
||||||
valid={password.length >= 8}
|
|
||||||
validText="Password meets the length requirement."
|
|
||||||
invalidText="Password must be at least 8 characters."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<PasswordField
|
|
||||||
id="confirm-password"
|
|
||||||
label="Confirm new password"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={setConfirmPassword}
|
|
||||||
describedBy={confirmPassword ? "password-match-status" : undefined}
|
|
||||||
invalid={confirmPassword.length > 0 && confirmPassword !== password}
|
|
||||||
/>
|
|
||||||
{confirmPassword && (
|
|
||||||
<ValidationMessage
|
|
||||||
id="password-match-status"
|
|
||||||
valid={confirmPassword === password}
|
|
||||||
validText="Passwords match."
|
|
||||||
invalidText="Passwords do not match."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ErrorMessage message={error} />
|
|
||||||
<PrimaryButton
|
|
||||||
loading={loading}
|
|
||||||
disabled={password.length < 8 || confirmPassword !== password}
|
|
||||||
>
|
|
||||||
Set New Password
|
|
||||||
</PrimaryButton>
|
|
||||||
<SecondaryButton onClick={returnToLogin}>Cancel</SecondaryButton>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{stage === "success" && (
|
|
||||||
<div className="space-y-4 text-center">
|
|
||||||
<div className="rounded-xl bg-success-bg px-4 py-3 text-sm text-success" role="status">
|
|
||||||
Your password has been reset successfully.
|
|
||||||
</div>
|
|
||||||
<PrimaryButton loading={false} onClick={returnToLogin}>Sign In</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
|
{/* Login card */}
|
||||||
|
<div className="rounded-[2rem] border border-border-light bg-bg-surface/90 p-6 shadow-[var(--shadow-modal)] backdrop-blur sm:p-8">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block text-sm font-medium text-text-secondary mb-1.5"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={isSetup ? "Create a new password" : "Enter your password"}
|
||||||
|
autoFocus
|
||||||
|
className="min-h-12 w-full rounded-xl border border-border bg-bg-surface-alt/60 px-4 text-text-heading placeholder:text-text-muted focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-error bg-error-bg rounded-lg px-3 py-2.5">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !password}
|
||||||
|
className="min-h-12 w-full rounded-xl bg-primary px-4 font-bold text-white transition-colors hover:bg-primary-hover disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="animate-spin h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{isSetup ? "Saving..." : "Signing in..."}
|
||||||
|
</span>
|
||||||
|
) : isSetup ? (
|
||||||
|
"Save Password & Login"
|
||||||
|
) : (
|
||||||
|
"Sign In"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</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,9 +4,8 @@ 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: SharedGroupData }) {
|
export function SharedGroupViewer({ data }: { data: any }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|
@ -19,7 +18,7 @@ export function SharedGroupViewer({ data }: { data: SharedGroupData }) {
|
||||||
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) => {
|
items.forEach((item: any) => {
|
||||||
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) {
|
||||||
|
|
@ -72,7 +71,7 @@ export function SharedGroupViewer({ data }: { data: SharedGroupData }) {
|
||||||
</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) => (
|
{items.map((item: any) => (
|
||||||
<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,11 +6,10 @@ 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: "flashcards" | "quizzes";
|
type: string;
|
||||||
data: SharedStudyItem;
|
data: any;
|
||||||
groupMode?: boolean;
|
groupMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,7 +36,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) => q.category.toUpperCase()))).join(" · ")
|
? Array.from(new Set(data.questions.map((q: any) => q.category.toUpperCase()))).join(" · ")
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -138,20 +137,18 @@ 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={{
|
||||||
id: data.id,
|
...data,
|
||||||
name: data.name,
|
|
||||||
questions: data.questions ?? [],
|
|
||||||
progress: [], // Start fresh since it's shared
|
progress: [], // Start fresh since it's shared
|
||||||
}}
|
}}
|
||||||
retakeIds={null}
|
retakeIds={null}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,6 @@ 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<{
|
||||||
|
|
@ -51,17 +48,17 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetData: SharedStudyItem | SharedGroupData | null = null;
|
let targetData: any = null;
|
||||||
let targetType: SharedContentType = type === "flashcards" ? "flashcards" : "quizzes";
|
let targetType = type;
|
||||||
|
|
||||||
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) ?? null;
|
targetData = link.group.decks.find(d => d.id === itemId);
|
||||||
targetType = "flashcards";
|
targetType = "flashcards";
|
||||||
} else {
|
} else {
|
||||||
targetData = link.group.quizSets.find(q => q.id === itemId) ?? null;
|
targetData = link.group.quizSets.find(q => q.id === itemId);
|
||||||
targetType = "quizzes";
|
targetType = "quizzes";
|
||||||
}
|
}
|
||||||
if (!targetData) notFound();
|
if (!targetData) notFound();
|
||||||
|
|
@ -72,8 +69,6 @@ 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 */}
|
||||||
|
|
@ -110,11 +105,11 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
||||||
<main className="flex-1 overflow-y-auto">
|
<main className="flex-1 overflow-y-auto">
|
||||||
<div className="mx-auto max-w-5xl px-4 py-6 sm:px-6 sm:py-8 lg:px-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 as SharedGroupData} />
|
<SharedGroupViewer data={targetData} />
|
||||||
) : (
|
) : (
|
||||||
<SharedViewer
|
<SharedViewer
|
||||||
type={targetType}
|
type={targetType}
|
||||||
data={targetData as SharedStudyItem}
|
data={targetData}
|
||||||
groupMode={type === "groups"}
|
groupMode={type === "groups"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
|
|
||||||
interface DailyActivity {
|
|
||||||
date: string;
|
|
||||||
flashcards: number;
|
|
||||||
questions: number;
|
|
||||||
arcade: number;
|
|
||||||
total: number;
|
|
||||||
level: 0 | 1 | 2 | 3 | 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActivitySummary {
|
|
||||||
days: DailyActivity[];
|
|
||||||
today: string;
|
|
||||||
currentStreak: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const levelClasses = [
|
|
||||||
"bg-white/10",
|
|
||||||
"bg-primary/45",
|
|
||||||
"bg-primary",
|
|
||||||
"bg-accent",
|
|
||||||
"bg-[#fbbf24]",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function ActivityBanner({ onNewClass }: { onNewClass: () => void }) {
|
|
||||||
const [summary, setSummary] = useState<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`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
export function ArcadeGameShell({
|
|
||||||
title,
|
|
||||||
exitHref,
|
|
||||||
elapsedSeconds,
|
|
||||||
complete,
|
|
||||||
worldClassName = "connections-world connections-stage",
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
exitHref: string;
|
|
||||||
elapsedSeconds: number;
|
|
||||||
complete: boolean;
|
|
||||||
worldClassName?: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
function exitGame() {
|
|
||||||
if (!complete && !window.confirm("Leave this round? Your progress will not be saved.")) return;
|
|
||||||
router.push(exitHref);
|
|
||||||
}
|
|
||||||
const minutes = Math.floor(elapsedSeconds / 60);
|
|
||||||
const seconds = String(elapsedSeconds % 60).padStart(2, "0");
|
|
||||||
return (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { GenerateTab } from "@/components/import/GenerateTab";
|
|
||||||
import type { ArcadeGameKey, ArcadeImportBatchPreview } from "@/types/arcade";
|
|
||||||
|
|
||||||
export function ArcadeImportModal({
|
|
||||||
classId,
|
|
||||||
gameType,
|
|
||||||
onClose,
|
|
||||||
onImported,
|
|
||||||
}: {
|
|
||||||
classId: string;
|
|
||||||
gameType: ArcadeGameKey;
|
|
||||||
onClose: () => void;
|
|
||||||
onImported: () => void;
|
|
||||||
}) {
|
|
||||||
const [tab, setTab] = useState<"generate" | "import">("import");
|
|
||||||
const [rawJson, setRawJson] = useState("");
|
|
||||||
const [names, setNames] = useState<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,237 +0,0 @@
|
||||||
"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>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,269 +0,0 @@
|
||||||
"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>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
"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>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { ConnectionsGame } from "@/components/arcade/ConnectionsGame";
|
|
||||||
import { CrosswordGame } from "@/components/arcade/CrosswordGame";
|
|
||||||
|
|
||||||
export const ARCADE_RENDERERS = {
|
|
||||||
connections: ConnectionsGame,
|
|
||||||
crossword: CrosswordGame,
|
|
||||||
} as const;
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
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;
|
||||||
|
|
@ -56,7 +55,6 @@ 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 });
|
||||||
|
|
@ -122,12 +120,7 @@ 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 || gradingRef.current) return;
|
if (!currentCard || !hasFlippedOnce) 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 };
|
||||||
|
|
@ -137,7 +130,6 @@ 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);
|
||||||
|
|
@ -152,7 +144,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
}
|
}
|
||||||
}, 350);
|
}, 350);
|
||||||
},
|
},
|
||||||
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled, isShared]
|
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
|
|
@ -244,7 +236,6 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -74,8 +74,8 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
onCreated();
|
onCreated();
|
||||||
} catch (err: unknown) {
|
} catch (err: any) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to create deck");
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,12 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" | "connections" | "crossword" }) {
|
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" }) {
|
||||||
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}`)
|
||||||
|
|
@ -58,7 +52,7 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCopy() {
|
async function handleCopy() {
|
||||||
await navigator.clipboard.writeText(displayedInstructions);
|
await navigator.clipboard.writeText(instructions);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
}
|
}
|
||||||
|
|
@ -73,23 +67,9 @@ 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={displayedInstructions}
|
value={instructions}
|
||||||
onChange={(event) => setInstructions(batchOverride && event.target.value.startsWith(batchOverride) ? event.target.value.slice(batchOverride.length) : event.target.value)}
|
onChange={(e) => setInstructions(e.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"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,15 @@
|
||||||
|
|
||||||
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: QuizSummary;
|
quiz: any;
|
||||||
attempts: QuizAttempt[];
|
attempts: any[];
|
||||||
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<QuizAttempt | null>(null);
|
const [selectedAttempt, setSelectedAttempt] = useState<any | 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: QuizSummary;
|
quiz: any;
|
||||||
|
attempt: any;
|
||||||
answeredIds: string[];
|
answeredIds: string[];
|
||||||
answers: Record<string, string[]>;
|
answers: Record<string, string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CategoryBreakdown({ quiz, answeredIds, answers }: CategoryBreakdownProps) {
|
export function CategoryBreakdown({ quiz, attempt, 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) => x.id === qId);
|
const q = quiz.questions.find((x: any) => x.id === qId);
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
|
|
||||||
const cat = q.category.trim().toLowerCase();
|
const cat = q.category.trim().toLowerCase();
|
||||||
|
|
@ -23,11 +23,7 @@ export function CategoryBreakdown({ quiz, answeredIds, answers }: CategoryBreakd
|
||||||
stats[cat] = { earned: 0, possible: 0 };
|
stats[cat] = { earned: 0, possible: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const formattedQ = {
|
const formattedQ = { id: q.id, type: q.type, options: q.options };
|
||||||
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,21 +4,14 @@ 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: QuizSummary;
|
quiz: any; // QuizData
|
||||||
attempt: QuizAttempt;
|
attempt: any; // 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 {
|
||||||
|
|
@ -26,16 +19,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: ReviewItem[] = [];
|
const reviewItems: any[] = [];
|
||||||
const missedIds: string[] = [];
|
const missedIds: string[] = [];
|
||||||
|
|
||||||
const answeredIds = Object.keys(answers);
|
const answeredIds = Object.keys(answers);
|
||||||
const questionsToReview = quiz.questions.filter((q) => answeredIds.includes(q.id));
|
const questionsToReview = quiz.questions.filter((q: any) => answeredIds.includes(q.id));
|
||||||
|
|
||||||
questionsToReview.forEach((q) => {
|
questionsToReview.forEach((q: any) => {
|
||||||
const formattedQ = {
|
const formattedQ = {
|
||||||
id: q.id,
|
id: q.id,
|
||||||
type: q.type as "MULTIPLE_CHOICE" | "SATA",
|
type: q.type,
|
||||||
options: q.options,
|
options: q.options,
|
||||||
};
|
};
|
||||||
const score = scoreQuestion(formattedQ, answers[q.id] || []);
|
const score = scoreQuestion(formattedQ, answers[q.id] || []);
|
||||||
|
|
@ -99,7 +92,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const allIds = quiz.questions.map((q) => q.id).sort(() => Math.random() - 0.5);
|
const allIds = quiz.questions.map((q: any) => 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"
|
||||||
|
|
@ -112,7 +105,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} answeredIds={answeredIds} answers={answers} />
|
<CategoryBreakdown quiz={quiz} attempt={attempt} answeredIds={answeredIds} answers={answers} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Review List */}
|
{/* Review List */}
|
||||||
|
|
@ -120,7 +113,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) => {
|
{reviewItems.map((item, idx) => {
|
||||||
const q = item.question;
|
const q = item.question;
|
||||||
const sels = item.selections;
|
const sels = item.selections;
|
||||||
return (
|
return (
|
||||||
|
|
@ -135,7 +128,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) => {
|
{q.options.map((opt: any) => {
|
||||||
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,12 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect } 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;
|
||||||
|
|
@ -41,7 +39,6 @@ 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[]>>({});
|
||||||
|
|
@ -49,7 +46,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<QuizAttempt | null>(null);
|
const [resultsData, setResultsData] = useState<any | null>(null);
|
||||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
// Auto-hide toast
|
// Auto-hide toast
|
||||||
|
|
@ -197,11 +194,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
if (isSubmitted || !currentQId || submittingQuestionRef.current === currentQId) return;
|
if (isSubmitted || !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);
|
||||||
|
|
@ -209,7 +202,6 @@ 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);
|
||||||
|
|
@ -298,7 +290,6 @@ 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
|
||||||
|
|
@ -351,11 +342,11 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
<div className="px-5 pb-6 pt-6 md:px-10 md:pb-10 md:pt-8">
|
<div className="px-5 pb-6 pt-6 md:px-10 md:pb-10 md:pt-8">
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div className="mb-8 flex items-start justify-between gap-3">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<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">
|
<span className="inline-flex px-3 py-1 rounded-md bg-primary/10 text-primary text-xs font-bold uppercase tracking-wider">
|
||||||
{currentQ.category}
|
{currentQ.category}
|
||||||
</span>
|
</span>
|
||||||
<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">
|
<span className="inline-flex px-3 py-1 rounded-md bg-amber-500/10 text-amber-600 text-xs font-bold uppercase tracking-wider">
|
||||||
{currentQ.type === "MULTIPLE_CHOICE" ? "Multiple Choice" : "Select All That Apply"}
|
{currentQ.type === "MULTIPLE_CHOICE" ? "Multiple Choice" : "Select All That Apply"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -383,7 +374,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="flex items-center gap-1.5 text-sm font-bold text-success">
|
<div className="text-success flex items-center gap-1.5 text-sm font-bold">
|
||||||
<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>
|
||||||
|
|
@ -401,7 +392,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={`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}`}
|
className={`flex min-h-14 items-center gap-4 rounded-2xl border px-4 py-4 transition-all duration-200 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 ${
|
||||||
|
|
@ -425,7 +416,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
{opt.text}
|
{opt.text}
|
||||||
</div>
|
</div>
|
||||||
{rightIcon && (
|
{rightIcon && (
|
||||||
<div className="col-start-2 min-w-0 md:flex-shrink-0 md:pl-2">
|
<div className="flex-shrink-0 pl-2">
|
||||||
{rightIcon}
|
{rightIcon}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,365 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,220 +0,0 @@
|
||||||
"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,12 +2,14 @@
|
||||||
|
|
||||||
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({ className }: ClassHeaderProps) {
|
export function ClassHeader({ classSlug, 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
|
||||||
|
|
@ -21,7 +23,8 @@ export function ClassHeader({ 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-7 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
|
|
@ -37,5 +40,8 @@ export function ClassHeader({ className }: ClassHeaderProps) {
|
||||||
</div>
|
</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>
|
<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} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter, usePathname } from "next/navigation";
|
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() {
|
function Mark() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -20,37 +19,6 @@ export function Navbar() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const parts = pathname.split("/").filter(Boolean);
|
const parts = pathname.split("/").filter(Boolean);
|
||||||
const classSlug = parts[0] && parts[0] !== "shared" ? parts[0] : null;
|
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" });
|
||||||
|
|
@ -79,28 +47,20 @@ export function Navbar() {
|
||||||
{classSlug && (
|
{classSlug && (
|
||||||
<div className="pt-6">
|
<div className="pt-6">
|
||||||
<p className="mb-2 px-3 text-[10px] font-bold uppercase tracking-[0.18em] text-text-muted">Current class</p>
|
<p className="mb-2 px-3 text-[10px] font-bold uppercase tracking-[0.18em] text-text-muted">Current class</p>
|
||||||
{STUDY_MODES.map((mode, index) => {
|
<Link
|
||||||
const isActive = pathname.includes(`/${mode.path}`);
|
href={`/${classSlug}/flashcards`}
|
||||||
return (
|
onClick={() => setOpen(false)}
|
||||||
<Link
|
className={`flex min-h-11 items-center gap-3 rounded-xl px-3 text-sm font-semibold transition-colors ${pathname.includes("/flashcards") ? "bg-bg-callout text-primary" : "text-text-secondary hover:bg-bg-surface-alt hover:text-text-heading"}`}
|
||||||
key={mode.key}
|
>
|
||||||
href={`/${classSlug}/${mode.path}`}
|
<span aria-hidden>▤</span> Flashcards
|
||||||
onClick={() => setOpen(false)}
|
</Link>
|
||||||
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"}`}
|
<Link
|
||||||
>
|
href={`/${classSlug}/quizzes`}
|
||||||
<span aria-hidden>{mode.icon}</span> {mode.label}
|
onClick={() => setOpen(false)}
|
||||||
{mode.key === "spaced-repetition" && hasReadyCards && (
|
className={`mt-1 flex min-h-11 items-center gap-3 rounded-xl px-3 text-sm font-semibold transition-colors ${pathname.includes("/quizzes") ? "bg-bg-callout text-primary" : "text-text-secondary hover:bg-bg-surface-alt hover:text-text-heading"}`}
|
||||||
<span
|
>
|
||||||
className="ml-auto grid h-5 w-5 place-items-center rounded-full bg-error-bg text-xs font-extrabold text-error"
|
<span aria-hidden>✓</span> Quizzes
|
||||||
title="Spaced repetition cards are ready to study"
|
</Link>
|
||||||
>
|
|
||||||
<span aria-hidden>!</span>
|
|
||||||
<span className="sr-only">Cards ready to study</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -119,11 +79,11 @@ export function Navbar() {
|
||||||
|
|
||||||
return (
|
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">
|
<aside className="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}
|
{nav}
|
||||||
</aside>
|
</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">
|
<header className="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">
|
<Link href="/" className="flex items-center gap-2.5">
|
||||||
<Mark />
|
<Mark />
|
||||||
<span className="editorial-title text-lg text-text-heading">Study Desk</span>
|
<span className="editorial-title text-lg text-text-heading">Study Desk</span>
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,20 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect } 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, compact = false }: ShareMenuProps) {
|
export function ShareMenu({ targetType, contentId, classSlug }: 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;
|
||||||
|
|
@ -81,9 +62,8 @@ export function ShareMenu({ targetType, contentId, classSlug, compact = false }:
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
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`}
|
className="grid h-10 w-10 place-items-center rounded-xl 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">
|
||||||
|
|
@ -94,7 +74,7 @@ export function ShareMenu({ targetType, contentId, classSlug, compact = false }:
|
||||||
{open && (
|
{open && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||||
<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)]">
|
<div className="absolute right-0 z-50 mt-2 w-72 origin-top-right animate-slide-up rounded-2xl border border-border-light bg-bg-surface p-5 shadow-[var(--shadow-modal)]">
|
||||||
<h4 className="editorial-title mb-3 text-xl text-text-heading">Public sharing</h4>
|
<h4 className="editorial-title mb-3 text-xl text-text-heading">Public sharing</h4>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
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,8 +1,6 @@
|
||||||
export const STUDY_MODES = [
|
export const STUDY_MODES = [
|
||||||
{ key: "flashcards", label: "Flashcards", path: "flashcards", icon: "▤" },
|
{ key: "flashcards", label: "Flashcards", path: "flashcards" },
|
||||||
{ key: "quizzes", label: "Quizzes", path: "quizzes", icon: "✓" },
|
{ key: "quizzes", label: "Quizzes", path: "quizzes" },
|
||||||
{ 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,38 +72,8 @@ 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,38 +96,8 @@ 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,13 +395,7 @@ export const ModelName = {
|
||||||
ShareLink: 'ShareLink',
|
ShareLink: 'ShareLink',
|
||||||
AuthSecurity: 'AuthSecurity',
|
AuthSecurity: 'AuthSecurity',
|
||||||
Setting: 'Setting',
|
Setting: 'Setting',
|
||||||
StudyActivity: 'StudyActivity',
|
MaterialGroup: 'MaterialGroup'
|
||||||
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]
|
||||||
|
|
@ -417,7 +411,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" | "studyActivity" | "arcadePack" | "arcadeAttempt" | "materialGroup" | "spacedRepetitionSet" | "spacedRepetitionSetDeck" | "spacedRepetitionCardState"
|
modelProps: "class" | "deck" | "flashcard" | "quizSet" | "question" | "answerOption" | "studyProgress" | "quizAttempt" | "shareLink" | "authSecurity" | "setting" | "materialGroup"
|
||||||
txIsolationLevel: TransactionIsolationLevel
|
txIsolationLevel: TransactionIsolationLevel
|
||||||
}
|
}
|
||||||
model: {
|
model: {
|
||||||
|
|
@ -1235,228 +1229,6 @@ 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
|
||||||
|
|
@ -1531,228 +1303,6 @@ 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: {
|
||||||
|
|
@ -1920,52 +1470,6 @@ 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',
|
||||||
|
|
@ -1978,52 +1482,6 @@ 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'
|
||||||
|
|
@ -2201,13 +1659,7 @@ 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,13 +62,7 @@ export const ModelName = {
|
||||||
ShareLink: 'ShareLink',
|
ShareLink: 'ShareLink',
|
||||||
AuthSecurity: 'AuthSecurity',
|
AuthSecurity: 'AuthSecurity',
|
||||||
Setting: 'Setting',
|
Setting: 'Setting',
|
||||||
StudyActivity: 'StudyActivity',
|
MaterialGroup: 'MaterialGroup'
|
||||||
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]
|
||||||
|
|
@ -215,52 +209,6 @@ 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',
|
||||||
|
|
@ -273,52 +221,6 @@ 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,11 +19,5 @@ 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'
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -219,8 +219,6 @@ 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 = {
|
||||||
|
|
@ -232,8 +230,6 @@ 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<{
|
||||||
|
|
@ -248,8 +244,6 @@ 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 = {
|
||||||
|
|
@ -285,8 +279,6 @@ 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 = {
|
||||||
|
|
@ -298,8 +290,6 @@ 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 = {
|
||||||
|
|
@ -311,8 +301,6 @@ 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 = {
|
||||||
|
|
@ -324,8 +312,6 @@ 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 = {
|
||||||
|
|
@ -433,20 +419,6 @@ 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
|
||||||
|
|
@ -461,20 +433,6 @@ 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
|
||||||
|
|
@ -483,8 +441,6 @@ 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 = {
|
||||||
|
|
@ -495,8 +451,6 @@ 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 = {
|
||||||
|
|
@ -523,8 +477,6 @@ 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 = {
|
||||||
|
|
@ -535,8 +487,6 @@ 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 = {
|
||||||
|
|
@ -547,8 +497,6 @@ 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 = {
|
||||||
|
|
@ -559,8 +507,6 @@ 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 = {
|
||||||
|
|
@ -587,8 +533,6 @@ 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 = {
|
||||||
|
|
@ -599,72 +543,6 @@ 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 = {
|
||||||
|
|
@ -675,8 +553,6 @@ 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 = {
|
||||||
|
|
@ -687,8 +563,6 @@ 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 = {
|
||||||
|
|
@ -715,8 +589,6 @@ 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 = {
|
||||||
|
|
@ -727,72 +599,6 @@ 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -804,16 +610,12 @@ 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -847,20 +649,6 @@ 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
|
||||||
|
|
@ -871,8 +659,6 @@ 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"]>
|
||||||
|
|
||||||
|
|
@ -905,8 +691,6 @@ 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> = {}
|
||||||
|
|
@ -918,8 +702,6 @@ 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
|
||||||
|
|
@ -1324,8 +1106,6 @@ 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.
|
||||||
|
|
@ -1822,54 +1602,6 @@ 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,7 +237,6 @@ 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 = {
|
||||||
|
|
@ -253,7 +252,6 @@ 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<{
|
||||||
|
|
@ -272,7 +270,6 @@ 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 = {
|
||||||
|
|
@ -314,7 +311,6 @@ 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 = {
|
||||||
|
|
@ -328,7 +324,6 @@ 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 = {
|
||||||
|
|
@ -342,7 +337,6 @@ 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 = {
|
||||||
|
|
@ -356,7 +350,6 @@ 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 = {
|
||||||
|
|
@ -579,20 +572,6 @@ 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
|
||||||
|
|
@ -603,7 +582,6 @@ 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 = {
|
||||||
|
|
@ -616,7 +594,6 @@ 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 = {
|
||||||
|
|
@ -667,7 +644,6 @@ 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 = {
|
||||||
|
|
@ -680,7 +656,6 @@ 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 = {
|
||||||
|
|
@ -709,7 +684,6 @@ 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 = {
|
||||||
|
|
@ -722,7 +696,6 @@ 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 = {
|
||||||
|
|
@ -735,7 +708,6 @@ 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 = {
|
||||||
|
|
@ -748,7 +720,6 @@ 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 = {
|
||||||
|
|
@ -777,7 +748,6 @@ 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 = {
|
||||||
|
|
@ -790,7 +760,6 @@ 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 = {
|
||||||
|
|
@ -803,7 +772,6 @@ 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 = {
|
||||||
|
|
@ -816,7 +784,6 @@ 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 = {
|
||||||
|
|
@ -845,7 +812,6 @@ 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 = {
|
||||||
|
|
@ -858,7 +824,6 @@ 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 = {
|
||||||
|
|
@ -871,7 +836,6 @@ 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 = {
|
||||||
|
|
@ -884,7 +848,6 @@ 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 = {
|
||||||
|
|
@ -912,74 +875,6 @@ 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
|
||||||
|
|
@ -999,7 +894,6 @@ 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 = {
|
||||||
|
|
@ -1012,7 +906,6 @@ 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 = {
|
||||||
|
|
@ -1043,7 +936,6 @@ 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 = {
|
||||||
|
|
@ -1056,7 +948,6 @@ 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 = {
|
||||||
|
|
@ -1076,13 +967,11 @@ 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1109,13 +998,6 @@ 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
|
||||||
|
|
@ -1130,7 +1012,6 @@ 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"]>
|
||||||
|
|
||||||
|
|
@ -1175,7 +1056,6 @@ 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> = {
|
||||||
|
|
@ -1195,7 +1075,6 @@ 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
|
||||||
|
|
@ -1604,7 +1483,6 @@ 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.
|
||||||
|
|
@ -2125,30 +2003,6 @@ 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,7 +217,6 @@ 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 = {
|
||||||
|
|
@ -227,7 +226,6 @@ 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<{
|
||||||
|
|
@ -240,7 +238,6 @@ 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 = {
|
||||||
|
|
@ -273,7 +270,6 @@ 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 = {
|
||||||
|
|
@ -282,7 +278,6 @@ export type FlashcardUncheckedCreateInput = {
|
||||||
front: string
|
front: string
|
||||||
back: string
|
back: string
|
||||||
sortOrder?: number
|
sortOrder?: number
|
||||||
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUncheckedCreateNestedManyWithoutFlashcardInput
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FlashcardUpdateInput = {
|
export type FlashcardUpdateInput = {
|
||||||
|
|
@ -291,7 +286,6 @@ 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 = {
|
||||||
|
|
@ -300,7 +294,6 @@ 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 = {
|
||||||
|
|
@ -368,11 +361,6 @@ 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[]
|
||||||
|
|
@ -415,26 +403,11 @@ 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 = {
|
||||||
|
|
@ -442,7 +415,6 @@ export type FlashcardUncheckedCreateWithoutDeckInput = {
|
||||||
front: string
|
front: string
|
||||||
back: string
|
back: string
|
||||||
sortOrder?: number
|
sortOrder?: number
|
||||||
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUncheckedCreateNestedManyWithoutFlashcardInput
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FlashcardCreateOrConnectWithoutDeckInput = {
|
export type FlashcardCreateOrConnectWithoutDeckInput = {
|
||||||
|
|
@ -481,54 +453,6 @@ 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
|
||||||
|
|
@ -541,7 +465,6 @@ 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 = {
|
||||||
|
|
@ -549,7 +472,6 @@ 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 = {
|
||||||
|
|
@ -560,35 +482,6 @@ 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
|
||||||
|
|
@ -597,8 +490,6 @@ 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<{
|
||||||
|
|
@ -630,8 +521,6 @@ 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>
|
||||||
|
|
@ -644,7 +533,6 @@ 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
|
||||||
|
|
@ -1047,7 +935,6 @@ 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.
|
||||||
|
|
@ -1480,30 +1367,6 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +0,0 @@
|
||||||
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 }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
export class ArcadeImportError extends Error {
|
|
||||||
constructor(message: string, public readonly details: string[] = []) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
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,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
|
|
||||||
import { crosswordTestPack } from "@/lib/arcade/crosswordTestData";
|
|
||||||
|
|
||||||
describe("Crossword grid engine", () => {
|
|
||||||
it("is deterministic and respects the selected target", () => {
|
|
||||||
const pack = crosswordTestPack();
|
|
||||||
const first = generateCrosswordLayout(pack, "mini", "same-seed");
|
|
||||||
const second = generateCrosswordLayout(pack, "mini", "same-seed");
|
|
||||||
expect(second).toEqual(first);
|
|
||||||
expect(first.entries).toHaveLength(15);
|
|
||||||
expect(first.entries.length).toBeLessThanOrEqual(first.targetCount);
|
|
||||||
expect(first.entries.length + first.omittedEntries.length).toBe(80);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates matching cells, shared intersections, and sequential clue numbers", () => {
|
|
||||||
const layout = generateCrosswordLayout(crosswordTestPack(), "standard", "grid-rules");
|
|
||||||
const cellByKey = new Map(layout.cells.map((cell) => [cell.key, cell]));
|
|
||||||
expect(layout.entries.length).toBeGreaterThanOrEqual(15);
|
|
||||||
expect(layout.cells.some((cell) => cell.entryIds.length > 1)).toBe(true);
|
|
||||||
for (const entry of layout.entries) {
|
|
||||||
expect(entry.cellKeys.map((key, index) => cellByKey.get(key)?.answer === entry.answer[index]).every(Boolean)).toBe(true);
|
|
||||||
expect(entry.number).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,266 +0,0 @@
|
||||||
import { deterministicShuffle } from "@/lib/arcade/connectionsEngine";
|
|
||||||
import type {
|
|
||||||
CrosswordCell,
|
|
||||||
CrosswordLayout,
|
|
||||||
CrosswordPlacedEntry,
|
|
||||||
CrosswordSize,
|
|
||||||
NormalizedCrosswordEntry,
|
|
||||||
NormalizedCrosswordPack,
|
|
||||||
} from "@/types/arcade";
|
|
||||||
|
|
||||||
export const CROSSWORD_SIZE_TARGETS: Record<CrosswordSize, number> = {
|
|
||||||
mini: 15,
|
|
||||||
standard: 30,
|
|
||||||
large: 50,
|
|
||||||
"extra-large": 80,
|
|
||||||
};
|
|
||||||
|
|
||||||
type Direction = "across" | "down";
|
|
||||||
|
|
||||||
interface WorkingCell {
|
|
||||||
answer: string;
|
|
||||||
directions: Set<Direction>;
|
|
||||||
entryIds: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WorkingEntry {
|
|
||||||
entry: NormalizedCrosswordEntry;
|
|
||||||
direction: Direction;
|
|
||||||
row: number;
|
|
||||||
column: number;
|
|
||||||
cellKeys: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Candidate {
|
|
||||||
entry: NormalizedCrosswordEntry;
|
|
||||||
direction: Direction;
|
|
||||||
row: number;
|
|
||||||
column: number;
|
|
||||||
intersections: number;
|
|
||||||
score: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function key(row: number, column: number) {
|
|
||||||
return `${row}:${column}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function coordinates(cellKey: string) {
|
|
||||||
const [row, column] = cellKey.split(":").map(Number);
|
|
||||||
return { row, column };
|
|
||||||
}
|
|
||||||
|
|
||||||
function bounds(cells: Map<string, WorkingCell>) {
|
|
||||||
const points = [...cells.keys()].map(coordinates);
|
|
||||||
const rows = points.map((point) => point.row);
|
|
||||||
const columns = points.map((point) => point.column);
|
|
||||||
return {
|
|
||||||
minRow: Math.min(...rows),
|
|
||||||
maxRow: Math.max(...rows),
|
|
||||||
minColumn: Math.min(...columns),
|
|
||||||
maxColumn: Math.max(...columns),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function placementCells(answer: string, row: number, column: number, direction: Direction) {
|
|
||||||
return Array.from(answer, (letter, index) => ({
|
|
||||||
letter,
|
|
||||||
row: row + (direction === "down" ? index : 0),
|
|
||||||
column: column + (direction === "across" ? index : 0),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function evaluatePlacement(
|
|
||||||
entry: NormalizedCrosswordEntry,
|
|
||||||
row: number,
|
|
||||||
column: number,
|
|
||||||
direction: Direction,
|
|
||||||
cells: Map<string, WorkingCell>
|
|
||||||
): Candidate | null {
|
|
||||||
const positions = placementCells(entry.answer, row, column, direction);
|
|
||||||
const before = direction === "across" ? key(row, column - 1) : key(row - 1, column);
|
|
||||||
const afterPosition = positions[positions.length - 1];
|
|
||||||
const after = direction === "across"
|
|
||||||
? key(afterPosition.row, afterPosition.column + 1)
|
|
||||||
: key(afterPosition.row + 1, afterPosition.column);
|
|
||||||
if (cells.has(before) || cells.has(after)) return null;
|
|
||||||
|
|
||||||
let intersections = 0;
|
|
||||||
for (const position of positions) {
|
|
||||||
const cellKey = key(position.row, position.column);
|
|
||||||
const existing = cells.get(cellKey);
|
|
||||||
if (existing) {
|
|
||||||
if (existing.answer !== position.letter || existing.directions.has(direction)) return null;
|
|
||||||
intersections += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const neighbors = direction === "across"
|
|
||||||
? [key(position.row - 1, position.column), key(position.row + 1, position.column)]
|
|
||||||
: [key(position.row, position.column - 1), key(position.row, position.column + 1)];
|
|
||||||
if (neighbors.some((neighbor) => cells.has(neighbor))) return null;
|
|
||||||
}
|
|
||||||
if (intersections === 0) return null;
|
|
||||||
|
|
||||||
const allKeys = [...cells.keys(), ...positions.map((position) => key(position.row, position.column))];
|
|
||||||
const allPoints = allKeys.map(coordinates);
|
|
||||||
const minRow = Math.min(...allPoints.map((point) => point.row));
|
|
||||||
const maxRow = Math.max(...allPoints.map((point) => point.row));
|
|
||||||
const minColumn = Math.min(...allPoints.map((point) => point.column));
|
|
||||||
const maxColumn = Math.max(...allPoints.map((point) => point.column));
|
|
||||||
const height = maxRow - minRow + 1;
|
|
||||||
const width = maxColumn - minColumn + 1;
|
|
||||||
const score = intersections * 1000 - height * width * 2 - Math.abs(height - width) * 8;
|
|
||||||
return { entry, row, column, direction, intersections, score };
|
|
||||||
}
|
|
||||||
|
|
||||||
function findCandidates(entry: NormalizedCrosswordEntry, cells: Map<string, WorkingCell>) {
|
|
||||||
const candidates: Candidate[] = [];
|
|
||||||
for (const [cellKey, cell] of cells) {
|
|
||||||
const point = coordinates(cellKey);
|
|
||||||
for (let index = 0; index < entry.answer.length; index += 1) {
|
|
||||||
if (entry.answer[index] !== cell.answer) continue;
|
|
||||||
if (!cell.directions.has("across")) {
|
|
||||||
const candidate = evaluatePlacement(entry, point.row, point.column - index, "across", cells);
|
|
||||||
if (candidate) candidates.push(candidate);
|
|
||||||
}
|
|
||||||
if (!cell.directions.has("down")) {
|
|
||||||
const candidate = evaluatePlacement(entry, point.row - index, point.column, "down", cells);
|
|
||||||
if (candidate) candidates.push(candidate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addEntry(candidate: Candidate, cells: Map<string, WorkingCell>, entries: WorkingEntry[]) {
|
|
||||||
const cellKeys: string[] = [];
|
|
||||||
for (const position of placementCells(candidate.entry.answer, candidate.row, candidate.column, candidate.direction)) {
|
|
||||||
const cellKey = key(position.row, position.column);
|
|
||||||
const existing = cells.get(cellKey);
|
|
||||||
if (existing) {
|
|
||||||
existing.directions.add(candidate.direction);
|
|
||||||
existing.entryIds.push(candidate.entry.id);
|
|
||||||
} else {
|
|
||||||
cells.set(cellKey, {
|
|
||||||
answer: position.letter,
|
|
||||||
directions: new Set([candidate.direction]),
|
|
||||||
entryIds: [candidate.entry.id],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
cellKeys.push(cellKey);
|
|
||||||
}
|
|
||||||
entries.push({ entry: candidate.entry, direction: candidate.direction, row: candidate.row, column: candidate.column, cellKeys });
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCandidate(pack: NormalizedCrosswordPack, targetCount: number, seed: string, pass: number) {
|
|
||||||
const order = deterministicShuffle(pack.content, `${seed}:entries:${pass}`);
|
|
||||||
const firstPool = order.slice(0, Math.min(12, order.length)).sort((a, b) => b.answer.length - a.answer.length);
|
|
||||||
const first = firstPool[pass % firstPool.length];
|
|
||||||
const cells = new Map<string, WorkingCell>();
|
|
||||||
const entries: WorkingEntry[] = [];
|
|
||||||
addEntry({ entry: first, direction: pass % 2 === 0 ? "across" : "down", row: 0, column: 0, intersections: 0, score: 0 }, cells, entries);
|
|
||||||
const remaining = order.filter((entry) => entry.id !== first.id);
|
|
||||||
|
|
||||||
while (entries.length < targetCount && remaining.length > 0) {
|
|
||||||
const candidates: Candidate[] = [];
|
|
||||||
let entriesWithCandidates = 0;
|
|
||||||
for (const entry of remaining) {
|
|
||||||
const entryCandidates = findCandidates(entry, cells);
|
|
||||||
if (entryCandidates.length === 0) continue;
|
|
||||||
entryCandidates.sort((left, right) => right.score - left.score);
|
|
||||||
candidates.push(...entryCandidates.slice(0, 3));
|
|
||||||
entriesWithCandidates += 1;
|
|
||||||
if (entriesWithCandidates >= 12) break;
|
|
||||||
}
|
|
||||||
if (candidates.length === 0) break;
|
|
||||||
candidates.sort((left, right) => right.score - left.score || left.entry.id.localeCompare(right.entry.id));
|
|
||||||
const top = candidates.slice(0, Math.min(8, candidates.length));
|
|
||||||
const selected = deterministicShuffle(top, `${seed}:choice:${pass}:${entries.length}`)[0];
|
|
||||||
addEntry(selected, cells, entries);
|
|
||||||
const usedIndex = remaining.findIndex((entry) => entry.id === selected.entry.id);
|
|
||||||
remaining.splice(usedIndex, 1);
|
|
||||||
}
|
|
||||||
return { cells, entries };
|
|
||||||
}
|
|
||||||
|
|
||||||
function candidateScore(candidate: ReturnType<typeof buildCandidate>) {
|
|
||||||
const box = bounds(candidate.cells);
|
|
||||||
const height = box.maxRow - box.minRow + 1;
|
|
||||||
const width = box.maxColumn - box.minColumn + 1;
|
|
||||||
const intersections = [...candidate.cells.values()].filter((cell) => cell.directions.size > 1).length;
|
|
||||||
return candidate.entries.length * 1_000_000 + intersections * 10_000 - height * width * 10 - Math.abs(height - width) * 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
function finalizeLayout(
|
|
||||||
pack: NormalizedCrosswordPack,
|
|
||||||
size: CrosswordSize,
|
|
||||||
seed: string,
|
|
||||||
candidate: ReturnType<typeof buildCandidate>
|
|
||||||
): CrosswordLayout {
|
|
||||||
const box = bounds(candidate.cells);
|
|
||||||
const offsetRow = -box.minRow;
|
|
||||||
const offsetColumn = -box.minColumn;
|
|
||||||
const starts = new Map<string, number>();
|
|
||||||
const sortedStarts = candidate.entries
|
|
||||||
.map((placed) => ({ key: key(placed.row + offsetRow, placed.column + offsetColumn), row: placed.row + offsetRow, column: placed.column + offsetColumn }))
|
|
||||||
.sort((left, right) => left.row - right.row || left.column - right.column);
|
|
||||||
for (const start of sortedStarts) {
|
|
||||||
if (!starts.has(start.key)) starts.set(start.key, starts.size + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries: CrosswordPlacedEntry[] = candidate.entries.map((placed) => {
|
|
||||||
const row = placed.row + offsetRow;
|
|
||||||
const column = placed.column + offsetColumn;
|
|
||||||
return {
|
|
||||||
...placed.entry,
|
|
||||||
direction: placed.direction,
|
|
||||||
row,
|
|
||||||
column,
|
|
||||||
number: starts.get(key(row, column)) ?? 0,
|
|
||||||
cellKeys: placed.cellKeys.map((cellKey) => {
|
|
||||||
const point = coordinates(cellKey);
|
|
||||||
return key(point.row + offsetRow, point.column + offsetColumn);
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const placedIds = new Set(entries.map((entry) => entry.id));
|
|
||||||
const cells: CrosswordCell[] = [...candidate.cells.entries()].map(([cellKey, cell]) => {
|
|
||||||
const point = coordinates(cellKey);
|
|
||||||
const normalizedKey = key(point.row + offsetRow, point.column + offsetColumn);
|
|
||||||
return {
|
|
||||||
key: normalizedKey,
|
|
||||||
row: point.row + offsetRow,
|
|
||||||
column: point.column + offsetColumn,
|
|
||||||
answer: cell.answer,
|
|
||||||
number: starts.get(normalizedKey),
|
|
||||||
entryIds: cell.entryIds,
|
|
||||||
};
|
|
||||||
}).sort((left, right) => left.row - right.row || left.column - right.column);
|
|
||||||
return {
|
|
||||||
size,
|
|
||||||
seed,
|
|
||||||
targetCount: CROSSWORD_SIZE_TARGETS[size],
|
|
||||||
rows: box.maxRow - box.minRow + 1,
|
|
||||||
columns: box.maxColumn - box.minColumn + 1,
|
|
||||||
cells,
|
|
||||||
entries,
|
|
||||||
omittedEntries: pack.content.filter((entry) => !placedIds.has(entry.id)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateCrosswordLayout(pack: NormalizedCrosswordPack, size: CrosswordSize, seed: string) {
|
|
||||||
const targetCount = CROSSWORD_SIZE_TARGETS[size];
|
|
||||||
let best = buildCandidate(pack, targetCount, seed, 0);
|
|
||||||
let bestScore = candidateScore(best);
|
|
||||||
if (best.entries.length === targetCount) return finalizeLayout(pack, size, seed, best);
|
|
||||||
|
|
||||||
for (let pass = 1; pass < 250; pass += 1) {
|
|
||||||
const candidate = buildCandidate(pack, targetCount, seed, pass);
|
|
||||||
const score = candidateScore(candidate);
|
|
||||||
if (score > bestScore) {
|
|
||||||
best = candidate;
|
|
||||||
bestScore = score;
|
|
||||||
}
|
|
||||||
if (best.entries.length === targetCount) break;
|
|
||||||
}
|
|
||||||
return finalizeLayout(pack, size, seed, best);
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { parseCrosswordImport } from "@/lib/arcade/crosswordImport";
|
|
||||||
import { crosswordTestPack } from "@/lib/arcade/crosswordTestData";
|
|
||||||
|
|
||||||
function importObject() {
|
|
||||||
const pack = crosswordTestPack();
|
|
||||||
return {
|
|
||||||
...pack,
|
|
||||||
content: pack.content.map(({ answer, displayAnswer, clue, alternateClue, explanation }, index) => ({
|
|
||||||
id: `term-${index + 1}`,
|
|
||||||
answer: index === 0 ? "Study-AA Word" : answer,
|
|
||||||
displayAnswer,
|
|
||||||
clue,
|
|
||||||
alternateClue,
|
|
||||||
explanation,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Crossword imports", () => {
|
|
||||||
it("normalizes one valid 80-entry pack and previews every size", () => {
|
|
||||||
const result = parseCrosswordImport(JSON.stringify(importObject()));
|
|
||||||
expect(result.preview.itemCount).toBe(80);
|
|
||||||
expect(result.preview.normalized.content[0].answer).toBe("STUDYAAWORD");
|
|
||||||
expect(result.preview.layoutPreviews?.map((layout) => layout.size)).toEqual(["mini", "standard", "large", "extra-large"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects packs with fewer or more than 80 entries", () => {
|
|
||||||
const short = importObject();
|
|
||||||
short.content.pop();
|
|
||||||
expect(() => parseCrosswordImport(JSON.stringify(short))).toThrow("exactly 80 entries");
|
|
||||||
const long = importObject();
|
|
||||||
long.content.push({ ...long.content[0], id: "extra", answer: "EXTRATERM" });
|
|
||||||
expect(() => parseCrosswordImport(JSON.stringify(long))).toThrow("exactly 80 entries");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects arrays, normalized duplicates, digits, and unknown keys", () => {
|
|
||||||
expect(() => parseCrosswordImport(JSON.stringify([importObject()]))).toThrow("one raw Crossword JSON object");
|
|
||||||
const duplicate = importObject();
|
|
||||||
duplicate.content[1].answer = "study aa word";
|
|
||||||
expect(() => parseCrosswordImport(JSON.stringify(duplicate))).toThrow("duplicated");
|
|
||||||
const digits = importObject();
|
|
||||||
digits.content[0].answer = "TERM2";
|
|
||||||
expect(() => parseCrosswordImport(JSON.stringify(digits))).toThrow("digits");
|
|
||||||
expect(() => parseCrosswordImport(JSON.stringify({ ...importObject(), extra: true }))).toThrow("does not match schema");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
|
|
||||||
import { CROSSWORD_SIZE_TARGETS, generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
|
|
||||||
import { parseAndRepairJson } from "@/lib/jsonRepair";
|
|
||||||
import { crosswordImportSchema } from "@/lib/validation/arcadeSchemas";
|
|
||||||
import type {
|
|
||||||
ArcadeImportBatchPreview,
|
|
||||||
ArcadeImportPreview,
|
|
||||||
ArcadeValidationReport,
|
|
||||||
CrosswordLayoutPreview,
|
|
||||||
CrosswordSize,
|
|
||||||
NormalizedCrosswordPack,
|
|
||||||
} from "@/types/arcade";
|
|
||||||
|
|
||||||
function clean(value: string) {
|
|
||||||
return value.trim().replace(/\s+/g, " ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeId(value: string, fallback: string) {
|
|
||||||
const id = value.trim().toLocaleLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
||||||
return id || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeCrosswordAnswer(value: string) {
|
|
||||||
return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^A-Za-z]/g, "").toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseCrosswordImport(rawJson: string): {
|
|
||||||
preview: ArcadeImportPreview<NormalizedCrosswordPack>;
|
|
||||||
report: ArcadeValidationReport;
|
|
||||||
} {
|
|
||||||
const trimmed = rawJson.trim();
|
|
||||||
if (trimmed.includes("```") || !trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
|
||||||
throw new ArcadeImportError("Paste one raw Crossword JSON object without Markdown fences, an array, or surrounding prose.");
|
|
||||||
}
|
|
||||||
const repaired = parseAndRepairJson(trimmed);
|
|
||||||
if (!repaired.success) throw new ArcadeImportError(repaired.error ?? "The JSON could not be parsed.");
|
|
||||||
if (Array.isArray(repaired.data)) throw new ArcadeImportError("Crossword imports accept one pack object at a time.");
|
|
||||||
const parsed = crosswordImportSchema.safeParse(repaired.data);
|
|
||||||
if (!parsed.success) {
|
|
||||||
throw new ArcadeImportError(
|
|
||||||
"The Crossword pack does not match schema version 1 and must contain exactly 80 entries.",
|
|
||||||
parsed.error.issues.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const warnings: string[] = [];
|
|
||||||
const usedIds = new Set<string>();
|
|
||||||
const usedAnswers = new Set<string>();
|
|
||||||
const normalized: NormalizedCrosswordPack = {
|
|
||||||
schemaVersion: 1,
|
|
||||||
type: "crossword",
|
|
||||||
name: clean(parsed.data.name),
|
|
||||||
description: parsed.data.description ? clean(parsed.data.description) : undefined,
|
|
||||||
settings: { ...parsed.data.settings },
|
|
||||||
content: parsed.data.content.map((entry, index) => {
|
|
||||||
if (/\d/.test(entry.answer)) throw new ArcadeImportError(`Entry ${index + 1} uses digits, which Crossword answers do not support.`);
|
|
||||||
const answer = normalizeCrosswordAnswer(entry.answer);
|
|
||||||
if (answer.length < 3 || answer.length > 18) {
|
|
||||||
throw new ArcadeImportError(`Entry ${index + 1} must contain 3 to 18 letters after normalization.`);
|
|
||||||
}
|
|
||||||
if (usedAnswers.has(answer)) throw new ArcadeImportError(`The normalized answer “${answer}” is duplicated.`);
|
|
||||||
usedAnswers.add(answer);
|
|
||||||
const fallbackId = `entry-${index + 1}`;
|
|
||||||
const id = safeId(entry.id ?? fallbackId, fallbackId);
|
|
||||||
if (usedIds.has(id)) throw new ArcadeImportError(`Entry id “${id}” is duplicated.`);
|
|
||||||
usedIds.add(id);
|
|
||||||
if (answer !== entry.answer.trim().toUpperCase()) warnings.push(`“${entry.answer}” will be placed as “${answer}”.`);
|
|
||||||
if (entry.clue.length > 180) warnings.push(`The clue for “${entry.displayAnswer ?? entry.answer}” is longer than 180 characters.`);
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
answer,
|
|
||||||
displayAnswer: clean(entry.displayAnswer ?? entry.answer),
|
|
||||||
clue: entry.clue.trim(),
|
|
||||||
alternateClue: entry.alternateClue.trim(),
|
|
||||||
explanation: entry.explanation.trim(),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const sizes: CrosswordSize[] = ["mini", "standard", "large", "extra-large"];
|
|
||||||
const layoutPreviews: CrosswordLayoutPreview[] = sizes.map((size) => {
|
|
||||||
const layout = generateCrosswordLayout(normalized, size, "crossword-import-preview-v1");
|
|
||||||
return {
|
|
||||||
size,
|
|
||||||
targetCount: CROSSWORD_SIZE_TARGETS[size],
|
|
||||||
placedCount: layout.entries.length,
|
|
||||||
omittedCount: layout.omittedEntries.length,
|
|
||||||
rows: layout.rows,
|
|
||||||
columns: layout.columns,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const mini = layoutPreviews[0];
|
|
||||||
if (mini.placedCount < 15) {
|
|
||||||
throw new ArcadeImportError(
|
|
||||||
"This pack cannot form a connected 15-word Mini crossword.",
|
|
||||||
[`The best preview placed ${mini.placedCount} of 15 target entries. Use answers with more shared letters.`]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const layout of layoutPreviews) {
|
|
||||||
if (layout.placedCount < layout.targetCount) {
|
|
||||||
warnings.push(`${layout.size} preview placed ${layout.placedCount} of ${layout.targetCount} target words.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const uniqueWarnings = [...new Set(warnings)];
|
|
||||||
const report = { wasRepaired: repaired.wasRepaired, warnings: uniqueWarnings };
|
|
||||||
return {
|
|
||||||
report,
|
|
||||||
preview: {
|
|
||||||
gameType: "crossword",
|
|
||||||
name: normalized.name,
|
|
||||||
description: normalized.description,
|
|
||||||
categories: [],
|
|
||||||
itemCount: 80,
|
|
||||||
wasRepaired: repaired.wasRepaired,
|
|
||||||
warnings: uniqueWarnings,
|
|
||||||
normalized,
|
|
||||||
source: repaired.data,
|
|
||||||
layoutPreviews,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseCrosswordImportBatch(rawJson: string): ArcadeImportBatchPreview<NormalizedCrosswordPack> {
|
|
||||||
const parsed = parseCrosswordImport(rawJson);
|
|
||||||
return { packs: [parsed.preview], count: 1, wasRepaired: parsed.preview.wasRepaired };
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import type { NormalizedCrosswordPack } from "@/types/arcade";
|
|
||||||
|
|
||||||
function letters(index: number) {
|
|
||||||
return `${String.fromCharCode(65 + Math.floor(index / 26))}${String.fromCharCode(65 + (index % 26))}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function crosswordTestPack(): NormalizedCrosswordPack {
|
|
||||||
return {
|
|
||||||
schemaVersion: 1,
|
|
||||||
type: "crossword",
|
|
||||||
name: "Test Crossword",
|
|
||||||
settings: { allowInstantCheck: false, allowHints: true },
|
|
||||||
content: Array.from({ length: 80 }, (_, index) => ({
|
|
||||||
id: `entry-${index + 1}`,
|
|
||||||
answer: `STUDY${letters(index)}WORD`,
|
|
||||||
displayAnswer: `Study ${letters(index)} Word`,
|
|
||||||
clue: `Test clue ${index + 1}`,
|
|
||||||
alternateClue: `Alternate test clue ${index + 1}`,
|
|
||||||
explanation: `Explanation ${index + 1}`,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
import { parseConnectionsImportBatch } from "@/lib/arcade/connectionsImport";
|
|
||||||
import { parseCrosswordImportBatch } from "@/lib/arcade/crosswordImport";
|
|
||||||
import type { ArcadeGameKey } from "@/types/arcade";
|
|
||||||
|
|
||||||
const CONNECTIONS_PROMPT = `You are generating a Connections study game as strict JSON.
|
|
||||||
Return exactly one JSON object with no Markdown fence or commentary.
|
|
||||||
|
|
||||||
Use this schema:
|
|
||||||
{"schemaVersion":1,"type":"connections","name":"...","description":"...","settings":{"groupCount":4,"itemsPerGroup":4,"allowedMistakes":4},"content":[{"id":"group-1","category":"...","items":["...","...","...","..."],"explanation":"..."}]}
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Generate exactly four groups of exactly four unique items.
|
|
||||||
- Every item must fit only its intended group within this board.
|
|
||||||
- Use specific, distinct category names and concise tiles of one to four words.
|
|
||||||
- Give every group a concise explanation.
|
|
||||||
- Do not create arbitrary leftover groups or use broad categories shared by most tiles.
|
|
||||||
- Base all content strictly on the study material below.
|
|
||||||
|
|
||||||
Material:
|
|
||||||
[paste your notes or lecture content here]`;
|
|
||||||
|
|
||||||
const CROSSWORD_PROMPT = `You are generating one Crossword study game as strict JSON.
|
|
||||||
Return exactly one JSON object with no Markdown fence or commentary.
|
|
||||||
|
|
||||||
Use this schema:
|
|
||||||
{"schemaVersion":1,"type":"crossword","name":"...","description":"...","settings":{"allowInstantCheck":false,"allowHints":true},"content":[{"id":"entry-1","answer":"...","displayAnswer":"...","clue":"...","alternateClue":"...","explanation":"..."}]}
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Generate exactly 80 entries in the content array.
|
|
||||||
- Every answer must be unique after spaces, punctuation, hyphens, apostrophes, and capitalization are removed.
|
|
||||||
- Answers must contain 3 to 18 letters after normalization. Do not use digits.
|
|
||||||
- Prefer terminology with shared letters so the application can construct a dense connected crossword.
|
|
||||||
- Each clue must uniquely identify its answer without repeating the answer.
|
|
||||||
- Provide a genuinely useful alternate clue and a concise educational explanation for every entry.
|
|
||||||
- Use displayAnswer to preserve spaces, punctuation, or natural capitalization when needed.
|
|
||||||
- Base all content strictly on the study material below.
|
|
||||||
|
|
||||||
Material:
|
|
||||||
[paste your notes or lecture content here]`;
|
|
||||||
|
|
||||||
export const ARCADE_SERVER_REGISTRY = {
|
|
||||||
connections: {
|
|
||||||
schemaVersion: 1,
|
|
||||||
preview: parseConnectionsImportBatch,
|
|
||||||
defaultInstructions: CONNECTIONS_PROMPT,
|
|
||||||
},
|
|
||||||
crossword: {
|
|
||||||
schemaVersion: 1,
|
|
||||||
preview: parseCrosswordImportBatch,
|
|
||||||
defaultInstructions: CROSSWORD_PROMPT,
|
|
||||||
},
|
|
||||||
} satisfies Record<ArcadeGameKey, {
|
|
||||||
schemaVersion: number;
|
|
||||||
preview: (rawJson: string) => unknown;
|
|
||||||
defaultInstructions: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export function getArcadeGameDefinition(gameType: ArcadeGameKey) {
|
|
||||||
return ARCADE_SERVER_REGISTRY[gameType];
|
|
||||||
}
|
|
||||||
|
|
@ -3,12 +3,6 @@ import { cookies } from "next/headers";
|
||||||
|
|
||||||
export interface SessionData {
|
export interface SessionData {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
sessionGeneration?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PasswordResetSessionData {
|
|
||||||
nonce?: string;
|
|
||||||
expiresAt?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionOptions: SessionOptions = {
|
const sessionOptions: SessionOptions = {
|
||||||
|
|
@ -21,27 +15,14 @@ const sessionOptions: SessionOptions = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const passwordResetSessionOptions: SessionOptions = {
|
|
||||||
password: sessionOptions.password,
|
|
||||||
cookieName: "study-app-password-reset",
|
|
||||||
cookieOptions: {
|
|
||||||
secure: sessionOptions.cookieOptions?.secure,
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: "lax" as const,
|
|
||||||
maxAge: 15 * 60,
|
|
||||||
path: "/",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getSession() {
|
export async function getSession() {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
return getIronSession<SessionData>(cookieStore, sessionOptions);
|
return getIronSession<SessionData>(cookieStore, sessionOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSession(sessionGeneration: number) {
|
export async function createSession() {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
session.isAuthenticated = true;
|
session.isAuthenticated = true;
|
||||||
session.sessionGeneration = sessionGeneration;
|
|
||||||
await session.save();
|
await session.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,23 +35,3 @@ export async function isAuthenticated() {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
return session.isAuthenticated === true;
|
return session.isAuthenticated === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPasswordResetSession() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
return getIronSession<PasswordResetSessionData>(
|
|
||||||
cookieStore,
|
|
||||||
passwordResetSessionOptions
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function authorizePasswordReset(nonce: string, expiresAt: string) {
|
|
||||||
const session = await getPasswordResetSession();
|
|
||||||
session.nonce = nonce;
|
|
||||||
session.expiresAt = expiresAt;
|
|
||||||
await session.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function destroyPasswordResetAuthorization() {
|
|
||||||
const session = await getPasswordResetSession();
|
|
||||||
session.destroy();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
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