Compare commits

..

No commits in common. "main" and "codex/newui" have entirely different histories.

119 changed files with 789 additions and 18988 deletions

6
.gitignore vendored
View file

@ -13,12 +13,6 @@
# testing
/coverage
# local SQLite study data
/dev.db
/dev.db-journal
/dev.db-shm
/dev.db-wal
# next.js
/.next/
/out/

559
AGENTS.md
View file

@ -1,242 +1,340 @@
# Study Desk - AI Contributor Guide
This file is the operating guide for AI agents and human contributors working in this repository.
This file is the project-specific operating guide for AI agents and human contributors working in this repository.
## Project summary
## Project overview
Study Desk is a self-hosted, single-user study application built with Next.js, React, TypeScript, Prisma, and SQLite.
Study Desk is a self-hosted, single-user study application. It organizes study material by class and supports:
Core capabilities:
- Flashcard decks with Markdown content, self-grading, shuffle persistence, resume state, and card management.
- Practice quizzes with multiple-choice and SATA questions, scoring, rationales, category breakdowns, retakes, and attempt history.
- Importing validated JSON files and generating importable JSON from user-provided study material.
- Material groups for organizing decks and quizzes.
- Password-protected private pages with public, read-only share links for decks, quizzes, and groups.
- Light and dark themes with responsive layouts for desktop and mobile use.
- 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
The application is intentionally a small single-user container rather than a multi-tenant SaaS product. There is no `User` model. Authentication state is held in an encrypted `iron-session` cookie, and content ownership is implicit in the one local installation.
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 the current source code and `prisma/schema.prisma` over the older planning document or the stock README.
When documentation conflicts with implementation, prefer:
## Mandatory project rules
1. Current source code
2. `prisma/schema.prisma`
3. Applied migrations
4. This file
5. Older planning documents and the stock README
### Next.js version and documentation
## Mandatory working rules
This repository uses Next.js `16.2.9`, React `19.2.4`, and the App Router. This is not a generic Next.js project. Before changing Next.js routing, layouts, dynamic parameters, server/client boundaries, middleware, or framework APIs:
- 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.
1. Read the relevant guide under `node_modules/next/dist/docs/`.
2. Follow the version installed in this repository, not a remembered API from an older Next.js version.
3. Pay attention to deprecation notices in build output. The current project still uses `src/middleware.ts`; Next.js currently warns that the `middleware` convention is moving toward `proxy`. Do not perform that migration as unrelated cleanup.
Relevant local references include:
- `node_modules/next/dist/docs/01-app/01-getting-started/05-server-and-client-components.md`
- `node_modules/next/dist/docs/01-app/01-getting-started/04-linking-and-navigating.md`
- `node_modules/next/dist/docs/01-app/03-api-reference/01-directives/use-client.md`
- `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/route.md`
- `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/dynamic-routes.md`
### General contribution behavior
- Ask a clarifying question before proceeding when missing information would materially change the implementation or create a risky assumption. Otherwise make a narrow, documented assumption and continue.
- Preserve existing user changes. Inspect `git status` before editing and do not use destructive commands such as `git reset --hard` or `git checkout --` unless explicitly requested.
- Use `apply_patch` for source and documentation edits.
- Keep changes scoped to the requested behavior. Do not rewrite unrelated components, migrate frameworks, or clean up the whole repository during a feature fix.
- Do not commit secrets, `.env` files, database contents, generated credentials, or private study material.
- Prefer TypeScript types over `any`, even though some existing files have `any` and the current lint baseline is not clean.
- 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.
- Do not manually edit an already-applied migration. Change `prisma/schema.prisma`, then create a new migration.
- Add dependencies only when the existing stack cannot reasonably solve the problem. If dependencies change, update `package-lock.json` and verify the build.
- Keep route handlers, services, and UI components separated by responsibility as described below.
## Stack and source of truth
## Stack and repository configuration
Confirm exact versions in `package.json`. The project currently uses:
| Area | Current implementation |
| --- | --- |
| Language | TypeScript, strict mode enabled |
| UI | React 19 with Next.js 16 App Router |
| Styling | Tailwind CSS v4 through `src/app/globals.css` and `@tailwindcss/postcss` |
| Fonts | `Manrope` for interface text and `Newsreader` for editorial headings via `next/font/google` |
| Persistence | SQLite with Prisma 7 and `@prisma/adapter-better-sqlite3` |
| Authentication | `iron-session` cookie, Argon2 password hashing, in-memory login rate limiter |
| Validation | Zod schemas in `src/lib/validation/` |
| Markdown | `react-markdown` with `remark-gfm` |
| Drag and drop | `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities` |
| JSON repair | `jsonrepair` through `src/lib/jsonRepair.ts` |
| Runtime | Node 22-slim in the Docker image; local Node/npm should be compatible with the lockfile |
| Deployment | Next standalone output in a single Docker container |
- 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 configuration files:
Important files:
- `package.json` - scripts and dependency versions.
- `next.config.ts` - standalone output and `better-sqlite3` as a server external package.
- `tsconfig.json` - strict TypeScript, bundler module resolution, and `@/*` mapped to `src/*`.
- `eslint.config.mjs` - Next core-web-vitals and Next TypeScript rules.
- `postcss.config.mjs` - Tailwind v4 PostCSS integration.
- `prisma.config.ts` - Prisma schema and SQLite URL resolution.
- `Dockerfile`, `docker-compose.yml`, `docker-compose.override.yml`, `docker-entrypoint.sh` - container build and runtime.
- `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
## Commands and local setup
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.
### Install and run locally
## Local development
For a clean checkout:
From the repository root:
```bash
npm ci
npm install
npx prisma generate
npm run dev
```
On Windows PowerShell, use `npm.cmd` and `npx.cmd` if script execution policy blocks the default shims.
Then open `http://localhost:3000`.
Useful scripts:
On Windows PowerShell, this environment may block the `npm.ps1` and `npx.ps1` shims because of execution policy. Use `npm.cmd` and `npx.cmd` when necessary:
```bash
npm run dev
npm run build
npm run start
npm run lint
```powershell
npm.cmd install
npx.cmd prisma generate
npm.cmd run dev
```
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.
The default local database is `dev.db` when `DATABASE_URL` is `file:./dev.db`. There is no seed script; classes and study material are created through the UI or API.
## Environment and authentication
### Environment variables
Common environment variables:
Copy `.env.example` to `.env` for local work. The current variables are:
- `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
| Variable | Purpose |
| --- | --- |
| `DATABASE_URL` | Prisma SQLite URL. Local default: `file:./dev.db`. |
| `SESSION_SECRET` | Secret used to encrypt the `iron-session` cookie. Use a random value of at least 32 characters outside local throwaway development. |
| `ADMIN_PASSWORD_HASH` | Present in `.env.example`, but the current login implementation does not read it. See authentication below. |
| `SECURE_COOKIES` | If set to `true` in production, the session cookie is marked `secure`. |
Current password behavior:
The `.env` file is ignored by Git. Never paste real secrets into source files, documentation, logs, or commits.
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.
### Password initialization behavior
Do not change password provisioning casually. Any change must update login, setup-status behavior, deployment documentation, and security handling together.
The current implementation initializes the password on the first login when the `Setting` row `admin_password_hash` does not exist:
Authentication state is stored in the `study-app-session` cookie.
1. The first password submitted at `/login` is Argon2-hashed and stored in the `Setting` table.
2. Later logins verify against that stored hash.
3. `ADMIN_PASSWORD_HASH` is currently not wired into this flow despite appearing in `.env.example` and the planning document.
Public paths include:
Do not change this behavior casually. If environment-driven password provisioning is added, update the login route, setup-status route, deployment documentation, and security handling together.
- `/login`
- `/api/auth/*`
- `/shared/*`
- Static assets and Next internals
### Available scripts
Everything else requires authentication unless explicitly designed and reviewed as public.
```bash
npm run dev # Next development server
npm run build # Production build; also runs TypeScript checking
npm run start # Start the built app locally
npm run lint # ESLint
```
Do not log passwords, secrets, full share tokens, or private study content.
There is currently no test runner configured in `package.json`. Use the production build plus focused manual/browser verification for UI changes.
## Database and migration safety
### Database workflow
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:
After changing `prisma/schema.prisma`:
```bash
npx prisma generate
npx prisma migrate dev --name describe_the_change
```
Use `npx prisma migrate deploy` for deployed environments.
Use `npx prisma migrate deploy` for an existing deployment. The Docker entrypoint runs `prisma migrate deploy` before starting the server.
## Architecture conventions
Be careful with `dev.db`: it may contain the developer's actual study data. Do not delete or reset it as part of normal feature work. Inspect migration status and make a backup before any destructive database operation.
## Docker details
The production image is a three-stage Node 22-slim build:
1. Install dependencies.
2. Generate Prisma and run `next build`.
3. Run the Next standalone server with SQLite data mounted at `/app/data`.
The runtime image sets `DATABASE_URL=file:/app/data/study.db` and `PORT=3726`. `docker-entrypoint.sh` applies migrations and then starts `server.js`.
Known compose detail: `docker-compose.yml` currently publishes `3000:3000`, while the production Dockerfile sets the runtime port to `3726`. Verify or correct that mapping before relying on the production compose file. `docker-compose.override.yml` is a development override that runs `npm run dev` and uses port 3000.
## Current project structure
The important source tree is:
```text
prisma/
schema.prisma # SQLite/Prisma source of truth
migrations/ # Applied database migrations
src/
app/
layout.tsx # Root metadata, fonts, theme bootstrap
globals.css # Tailwind v4, design tokens, global styles
icon.svg
login/page.tsx # Public login/setup screen
(protected)/
layout.tsx # Session check and authenticated navbar shell
page.tsx # Authenticated class library
[classSlug]/
layout.tsx # Class lookup, header, and study-mode tabs
page.tsx # Redirects to flashcards
flashcards/page.tsx # Grouped deck library and drag/drop ordering
flashcards/[deckId]/page.tsx
# Deck study/manage screen
quizzes/page.tsx # Grouped quiz library and drag/drop ordering
quizzes/[quizId]/page.tsx
# Quiz taking/history screen
shared/[classSlug]/[type]/[token]/
page.tsx # Public read-only share route
SharedViewer.tsx # Public deck/quiz viewer
SharedGroupViewer.tsx # Public group selector/viewer
api/
.../route.ts # Route handlers; see API map below
components/
flashcards/ # Flashcard viewer, manager, and list
quizzes/ # Quiz viewer, results, history, breakdown
import/ # Import modal and Create/Import/Generate tabs
ui/ # Navbar, class header/tabs, theme, share menu
services/
classService.ts
deckService.ts
cardService.ts
quizService.ts
progressService.ts
shareService.ts
settingsService.ts # Persistence for LLM instruction templates
lib/
db.ts # Prisma singleton with better-sqlite3 adapter
auth.ts # iron-session helpers
rateLimiter.ts # In-memory login sliding-window limiter
scoring.ts # Quiz scoring
shuffle.ts # Stable order/session helpers
jsonRepair.ts # JSON parse/repair pipeline
validation/importSchemas.ts
config/studyModes.ts # Central list of flashcards/quizzes modes
middleware.ts # Auth gate and public-path exceptions
generated/prisma/ # Generated Prisma client; do not hand edit
public/ # Static assets
```
`(protected)` is a route group and is not part of the URL. The `classSlug`, `deckId`, `quizId`, and share `token` segments are dynamic route parameters.
## Page routes and API surface
### Page routes
| Route | Behavior |
| --- | --- |
| `/login` | Public password setup/login page. |
| `/` | Authenticated class library. |
| `/<classSlug>` | Redirects to `/<classSlug>/flashcards`. |
| `/<classSlug>/flashcards` | Grouped flashcard deck library. |
| `/<classSlug>/flashcards/<deckId>` | Flashcard study or card-management view. |
| `/<classSlug>/quizzes` | Grouped quiz library. |
| `/<classSlug>/quizzes/<quizId>` | Quiz-taking or attempt-history view. |
| `/shared/<classSlug>/flashcards/<token>` | Public shared deck. |
| `/shared/<classSlug>/quizzes/<token>` | Public shared quiz. |
| `/shared/<classSlug>/groups/<token>` | Public shared group; `?itemId=...` can select an item. |
### API routes
All API routes are behind `src/middleware.ts` unless they are under `/api/auth`. The API is currently consumed by client components with `fetch`.
| Endpoint | Methods and purpose |
| --- | --- |
| `/api/auth/login` | `POST` login, first-time password setup, rate limiting, progressive lockout. |
| `/api/auth/logout` | `POST` destroy the session. |
| `/api/auth/setup-status` | `GET` whether the password setting exists. |
| `/api/classes` | `GET` list classes; `POST` create class. |
| `/api/classes/[id]` | `PATCH` rename; `DELETE` class and cascaded content. |
| `/api/decks` | `POST` validate and import a deck. |
| `/api/decks/list` | `GET` decks for `classId`. |
| `/api/decks/[id]` | `GET` deck with cards; `PATCH` rename/description; `DELETE`. |
| `/api/decks/[id]/cards` | `POST` append a card. |
| `/api/cards/[id]` | `PATCH` edit card; `DELETE` card. |
| `/api/decks/reorder` | `PATCH` persist deck order/group placement. |
| `/api/quizzes` | `POST` validate and import a quiz. |
| `/api/quizzes/list` | `GET` quizzes for `classId`. |
| `/api/quizzes/[id]` | `GET` quiz with questions/options; `PATCH` rename/description; `DELETE`. |
| `/api/quizzes/[id]/attempt` | `GET` attempt history; `POST` score and save an attempt. |
| `/api/quizzes/reorder` | `PATCH` persist quiz order/group placement. |
| `/api/material-groups` | `GET` groups by `classId` and optional `type`; `POST` create group. |
| `/api/material-groups/[id]` | `PATCH` rename/group metadata; `DELETE` group. |
| `/api/progress` | `GET`, `PATCH`, and `DELETE` resume progress for deck/quiz and mode. |
| `/api/settings/llm-instructions` | `GET`/`PATCH` saved generation instructions by `type`; `__RESET__` restores defaults. |
| `/api/share` | `GET` share state; `POST` toggle a deck, quiz, or group link. |
## Architecture and coding 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.
- Use Server Components by default for layouts and pages that only fetch or compose data.
- Add `"use client"` only where state, event handlers, effects, browser APIs, drag/drop, or client-side fetches are needed.
- Once a file is a Client Component, its directly imported module graph is included in the client bundle. Keep server-only database/auth code out of client imports.
- Do not access `window`, `localStorage`, `navigator`, or the clipboard API from a Server Component.
- Dynamic server route params use the current Next.js 16 conventions in this repository. Follow nearby examples such as `src/app/(protected)/[classSlug]/layout.tsx` and API `RouteContext` types instead of inventing older parameter types.
- Prefer `Link` for normal internal navigation. Use `useRouter` only when navigation follows an action or requires imperative behavior.
### API routes and services
### API handlers and services
Preferred route-handler flow:
The preferred API route shape is:
1. Parse and validate external input.
2. Delegate business logic to focused service or utility functions.
3. Return a useful status and JSON response.
1. Parse and validate request input.
2. Call one function in the matching service or low-level utility.
3. Return a `NextResponse` with a useful status and JSON body.
Business logic and Prisma calls normally belong in `src/services/` or a focused `src/lib/` module. Reuse existing services before creating new abstractions.
Business logic and Prisma calls belong in `src/services/` or a focused `src/lib/` utility. Reuse an existing service before creating a new one. `SKILL.md` contains the project-specific workflow for adding an API route.
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.
A few older routes still contain direct Prisma calls or loose input handling. Follow the preferred pattern for new work without broad refactoring unless the task specifically requests it.
### Validation
### Prisma and database access
- 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.
- Import the shared client from `@/lib/db`; do not instantiate a new `PrismaClient` in a request or component.
- Prisma uses the generated client at `src/generated/prisma`, configured by `prisma/schema.prisma`.
- Keep database names and relation behavior consistent with the schema: `Deck` and `QuizSet` belong to a `Class`; cards/questions/options cascade from their parent; group deletion sets item `groupId` to null; share links and progress cascade with their content.
- Use transactions for multi-row reorder/update operations where partial writes would be harmful.
- Handle missing records as 404s at the route boundary rather than leaking Prisma exceptions.
## UI and accessibility
### Validation and input handling
Use the existing design system in `src/app/globals.css`.
- Reuse `flashcardImportSchema` and `quizImportSchema` from `src/lib/validation/importSchemas.ts` for import payloads on both client preview and server commit.
- Do not trust client-side validation alone.
- Validate IDs, enum-like values (`DECK`/`QUIZ`, `SEQUENTIAL`/`SHUFFLED`, question types), required strings, and array shapes at the API boundary.
- Keep stored JSON fields parseable and handle malformed or stale JSON defensively. `jsonRepair.ts` is for repairing user/LLM-generated import text, not for hiding malformed database state.
Common tokens include:
### UI and design system
- `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`
- Use the CSS variables and Tailwind aliases defined in `src/app/globals.css`: `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`, and `error`.
- Preserve both light and dark theme behavior. Avoid introducing one-off colors when an existing token is appropriate.
- Use `editorial-title` for major serif headings, `font-sans`/Manrope for interface text, and the existing card/modal shadows and rounded-corner language.
- Maintain responsive behavior at mobile widths. Study pages, library cards, modals, and action rows must remain usable without horizontal overflow.
- Use visible focus states, descriptive `title`/`aria-label` text for icon-only buttons, semantic buttons/links, and keyboard-accessible controls.
- Keep Markdown rendering consistent with the existing `ReactMarkdown` + `remarkGfm` usage and `.markdown-content` styles.
- Use the existing `@dnd-kit` patterns when changing sortable cards or groups. Do not attach drag listeners to nested action buttons.
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
## Domain behavior to preserve
### 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.
- A deck contains ordered `Flashcard` rows with `front` and `back` strings.
- Study progress is stored in `StudyProgress` as JSON order/results and can be sequential or shuffled.
- Deleting a card can leave a stale ID in saved `orderJson`; the shuffle/session helpers filter and clamp stale IDs when resuming.
- The deck page supports study and manage views. Keep the existing viewer-header share action when adding other share entry points.
### Quizzes
- A quiz contains ordered questions with ordered answer options.
- A quiz contains ordered questions, each 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`.
- `SATA` questions may have multiple correct options and use partial credit in `src/lib/scoring.ts`.
- Attempts are persisted as `QuizAttempt` rows with score, maximum score, answer JSON, retake flag, and completion time.
- Partial retakes score only the questions included in the submitted answer set.
- The quiz viewer persists in-progress answers and position through `/api/progress`.
### Imports and generation
@ -270,81 +368,112 @@ Quiz import shape:
}
```
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`.
- Import UI is split into `ImportModal`, `CreateTab`, `ImportTab`, and `GenerateTab`. Keep that separation rather than creating one large modal component.
- Generation instructions are stored in the `Setting` table and are editable/resettable through `/api/settings/llm-instructions`. The app does not call an LLM itself; the Generate tab prepares instructions and accepts generated JSON from the user.
- Generated text may contain Markdown inside JSON string values. Preserve it and render it safely through the existing Markdown components.
### 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.
- `ShareLink` rows use UUID IDs as public tokens and can target a `DECK`, `QUIZ`, or `GROUP`.
- `src/components/ui/ShareMenu.tsx` is the shared UI for loading share state, toggling a link, and copying the public URL. Reuse it rather than duplicating share URL or group-inheritance logic.
- Public URLs are `/shared/<classSlug>/<type>/<token>`, where item types are `flashcards`, `quizzes`, and `groups`.
- A deck or quiz can appear publicly shared through its group. The API returns the group token and the UI includes `?itemId=...` when copying a link to an item in a shared group.
- Shared viewers are read-only and should not write authenticated progress. Preserve the distinction between protected study viewers and public shared viewers.
## Data model summary
The source of truth is `prisma/schema.prisma`:
- `Class` - named study space with a unique slug and sort order.
- `Deck` - flashcard collection belonging to a class, optionally assigned to a `MaterialGroup`.
- `Flashcard` - ordered front/back card belonging to a deck.
- `QuizSet` - quiz collection belonging to a class, optionally assigned to a `MaterialGroup`.
- `Question` - ordered quiz prompt, type, rationale, and category.
- `AnswerOption` - ordered answer option with `isCorrect`.
- `StudyProgress` - resumable deck/quiz state. Unique by parent and mode.
- `QuizAttempt` - completed quiz score and submitted answer JSON.
- `MaterialGroup` - typed (`DECK` or `QUIZ`) class grouping for library organization.
- `ShareLink` - optional one-to-one link for a deck, quiz, or group.
- `Setting` - key/value storage for the admin password hash and editable LLM instructions.
- `AuthSecurity` - singleton lockout/rate-limit state persisted in SQLite.
Most parent relations use cascading deletion. Treat deletion of a class, deck, quiz, question, or group as a data-affecting operation and preserve confirmation behavior in the UI.
## Authentication and security boundaries
`src/middleware.ts` allows these paths without a session:
- `/login`
- `/api/auth/*`
- `/shared/*`
- Static assets and Next internals
Everything else requires the `study-app-session` cookie. Individual API routes generally rely on middleware for the authenticated boundary, so a new public endpoint must be an explicit, reviewed decision.
The login route has two protections:
- An in-memory per-IP sliding-window limiter: 10 attempts per minute.
- Progressive lockout stored in `AuthSecurity`: one minute after 5 failures, five minutes after 10, thirty minutes after 15, and twenty-four hours after 20.
Do not log passwords, session secrets, full share tokens, or private study content. Treat public share routes as intentionally public and validate that the token, type, and class slug agree before returning content.
## Verification checklist
Before editing:
### 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.
- Inspect the target page/component, its API route, and its service before changing behavior.
- Read the relevant Next.js local guide if framework behavior is involved.
- Check whether the change affects the Prisma schema, migrations, auth boundary, public sharing, or generated client.
During implementation:
### During implementation
- Keep route, service, utility, and component responsibilities clear.
- Keep the route/service/component boundaries intact.
- 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.
- Reuse existing design tokens, shared components, and services.
- Consider loading, error, empty, mobile, keyboard, and both theme states.
- Avoid synchronous state updates directly inside effects when a state initializer, derived value, event handler, or async callback is more appropriate. The current ESLint configuration reports these patterns.
After editing:
### After editing
Run the narrowest relevant checks, then the full build when possible:
```bash
npm run lint
npm run build
```
If lint already fails, report baseline failures separately.
If lint is already failing, report baseline failures separately from failures introduced by the change. Do not silence rules or add broad ESLint disables just to make a task appear clean.
For UI work, manually verify:
For UI changes, manually verify at least the affected authenticated route and its corresponding public/shared route if applicable. A useful smoke-test path is:
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
1. Log in or complete first-time setup.
2. Create or open a class.
3. Import or edit representative flashcards and quizzes.
4. Exercise the changed action, including loading/error/empty states.
5. Check study resume/restart, drag/drop if relevant, and public sharing if relevant.
6. Check light/dark themes and a narrow mobile viewport.
## 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.
- `README.md` is still mostly the default create-next-app README and is not a reliable description of the current product.
- `study-app-implementation-plan.md` is a valuable architectural reference, but some details are historical and the current source code wins.
- `task.md` records completed implementation phases and is useful for feature context.
- `SKILL.md` is the project-specific workflow for adding API routes; follow it for new route work.
- The current full lint command has pre-existing errors in several files, including existing `any` usage and React effect-rule violations. Do not attribute those to an unrelated small change without checking the changed lines.
- A successful `next build` is the primary repository-wide compile/type verification currently available.
- Next build output currently warns about the deprecated `middleware` file convention. Treat that as known baseline unless the task is specifically about the migration.
## Extending the application
For a genuinely new study mode:
For a genuinely new study mode, follow the existing extensibility pattern:
1. Add or update the Prisma model and migration when persistence is required.
1. Add the domain model and migration in Prisma if persistence is needed.
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.
4. Add the page route and client components.
5. Add the mode to `src/config/studyModes.ts` if it belongs in shared class navigation.
6. Update public sharing, progress, import, or scoring only if the new mode needs those capabilities.
7. Verify auth, mobile layout, theme support, and any public/private boundary.
Do not add a new mode by placing unrelated logic into `deckService.ts`, `quizService.ts`, or a large page component.
Do not add a new mode by placing unrelated logic into `deckService.ts`, `quizService.ts`, or a giant page component. The existing separation is intentional and is the main maintainability boundary in this project.

BIN
dev.db Normal file

Binary file not shown.

840
package-lock.json generated
View file

@ -22,7 +22,6 @@
"react-dom": "19.2.4",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"ts-fsrs": "^5.4.1",
"zod": "^4.4.3"
},
"devDependencies": {
@ -34,8 +33,7 @@
"eslint-config-next": "16.2.9",
"prisma": "^7.8.0",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.1.10"
"typescript": "^5"
}
},
"node_modules/@alloc/quick-lru": {
@ -1351,16 +1349,6 @@
"node": ">=12.4.0"
}
},
"node_modules/@oxc-project/types": {
"version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@phc/format": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
@ -1749,293 +1737,6 @@
}
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.11.1",
"@napi-rs/wasm-runtime": "^1.1.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@ -2341,17 +2042,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@ -2361,13 +2051,6 @@
"@types/ms": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@ -2427,7 +2110,6 @@
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@ -3108,119 +2790,6 @@
"win32"
]
},
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.10",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
@ -3471,16 +3040,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@ -3836,16 +3395,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -4512,13 +4061,6 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
@ -5013,16 +4555,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@ -5042,16 +4574,6 @@
"node": ">=6"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/exsolve": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz",
@ -5275,21 +4797,6 @@
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -8168,20 +7675,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/obug": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/ohash": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
@ -8914,40 +8407,6 @@
"node": ">=0.10.0"
}
},
"node_modules/rolldown": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.139.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.1.5",
"@rolldown/binding-darwin-arm64": "1.1.5",
"@rolldown/binding-darwin-x64": "1.1.5",
"@rolldown/binding-freebsd-x64": "1.1.5",
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
"@rolldown/binding-linux-arm64-musl": "1.1.5",
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
"@rolldown/binding-linux-x64-gnu": "1.1.5",
"@rolldown/binding-linux-x64-musl": "1.1.5",
"@rolldown/binding-openharmony-arm64": "1.1.5",
"@rolldown/binding-wasm32-wasi": "1.1.5",
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
"@rolldown/binding-win32-x64-msvc": "1.1.5"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@ -9280,13 +8739,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@ -9381,13 +8833,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
@ -9685,23 +9130,6 @@
"node": ">=6"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@ -9751,16 +9179,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -9807,15 +9225,6 @@
"typescript": ">=4.8.4"
}
},
"node_modules/ts-fsrs": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/ts-fsrs/-/ts-fsrs-5.4.1.tgz",
"integrity": "sha512-mOp9+oexJexBTkwjg/jQI1aSUQRLIAvbimeKHLSmVdNJPwObugFNKmZkoggH5d6kZ0uaWLboP1Al1DnXAfIb9w==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@ -10237,236 +9646,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/vite": {
"version": "8.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
"integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.5",
"postcss": "^8.5.16",
"rolldown": "~1.1.4",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.3.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vite/node_modules/postcss": {
"version": "8.5.19",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
"integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vitest/node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -10571,23 +9750,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",

View file

@ -6,8 +6,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "vitest run"
"lint": "eslint"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@ -24,7 +23,6 @@
"react-dom": "19.2.4",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"ts-fsrs": "^5.4.1",
"zod": "^4.4.3"
},
"devDependencies": {
@ -36,7 +34,6 @@
"eslint-config-next": "16.2.9",
"prisma": "^7.8.0",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.1.10"
"typescript": "^5"
}
}

View file

@ -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");

View file

@ -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");

View file

@ -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");

View file

@ -17,8 +17,6 @@ model Class {
decks Deck[]
quizSets QuizSet[]
materialGroups MaterialGroup[]
spacedRepetitionSets SpacedRepetitionSet[]
arcadePacks ArcadePack[]
}
model Deck {
@ -35,7 +33,6 @@ model Deck {
cards Flashcard[]
shareLink ShareLink?
progress StudyProgress[]
spacedRepetitionMemberships SpacedRepetitionSetDeck[]
}
model Flashcard {
@ -46,7 +43,6 @@ model Flashcard {
sortOrder Int @default(0)
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
spacedRepetitionStates SpacedRepetitionCardState[]
}
model QuizSet {
@ -145,54 +141,6 @@ model Setting {
value String
}
model StudyActivity {
id String @id @default(uuid())
type String // "FLASHCARD" | "QUIZ_QUESTION" | "ARCADE_GROUP"
occurredAt DateTime @default(now())
@@index([occurredAt])
}
model ArcadePack {
id String @id @default(uuid())
classId String
gameType String
name String
description String?
schemaVersion Int
sourceJson String
normalizedJson String
validationReportJson String
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
attempts ArcadeAttempt[]
@@index([classId, gameType, sortOrder])
}
model ArcadeAttempt {
id String @id @default(uuid())
arcadePackId String
mode String
score Int
maxScore Int
accuracy Float
durationSeconds Int
mistakes Int
hintsUsed Int
settingsJson String
resultsJson String
seed String
completedAt DateTime @default(now())
arcadePack ArcadePack @relation(fields: [arcadePackId], references: [id], onDelete: Cascade)
@@index([arcadePackId, completedAt])
}
model MaterialGroup {
id String @id @default(uuid())
classId String
@ -206,60 +154,3 @@ model MaterialGroup {
quizSets QuizSet[]
shareLink ShareLink?
}
model SpacedRepetitionSet {
id String @id @default(uuid())
classId String
name String
description String?
newCardsPerDay Int @default(30)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
decks SpacedRepetitionSetDeck[]
cardStates SpacedRepetitionCardState[]
@@index([classId, sortOrder])
}
model SpacedRepetitionSetDeck {
setId String
deckId String
sortOrder Int @default(0)
addedAt DateTime @default(now())
set SpacedRepetitionSet @relation(fields: [setId], references: [id], onDelete: Cascade)
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
@@id([setId, deckId])
@@index([deckId])
}
model SpacedRepetitionCardState {
id String @id @default(uuid())
setId String
flashcardId String
due DateTime
stability Float
difficulty Float
elapsedDays Int
scheduledDays Int
learningSteps Int @default(0)
reps Int
lapses Int
state Int
firstReviewedAt DateTime @default(now())
lastReview DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
set SpacedRepetitionSet @relation(fields: [setId], references: [id], onDelete: Cascade)
flashcard Flashcard @relation(fields: [flashcardId], references: [id], onDelete: Cascade)
@@unique([setId, flashcardId])
@@index([setId, due])
@@index([setId, firstReviewedAt])
@@index([flashcardId])
}

View file

@ -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" }} />;
}

View file

@ -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} />;
}

View file

@ -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" }} />;
}

View file

@ -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} />;
}

View file

@ -1,3 +0,0 @@
export default function ArcadeLayout({ children }: { children: React.ReactNode }) {
return <div className="arcade-route">{children}</div>;
}

View file

@ -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>
);
}

View file

@ -27,7 +27,6 @@ import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { ReorderItem } from "@/types/study";
interface DeckItem {
id: string;
@ -66,14 +65,7 @@ function getProgressLabel(deck: DeckItem) {
return `${current}/${total}, ${modeLabel} · ${correctCount}`;
}
interface SortableDeckCardProps {
deck: DeckItem;
onEdit: (deck: DeckItem) => void;
onDelete: (deck: DeckItem) => void;
classSlug: string;
}
function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCardProps) {
function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: deck.id });
const style = {
transform: CSS.Transform.toString(transform),
@ -107,8 +99,7 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCar
)}
</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 w-full min-w-0 gap-2 sm:flex-1">
<div className="flex items-center gap-2 pt-4 border-t border-border-light pl-7">
{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">
@ -134,8 +125,6 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCar
Study
</Link>
)}
</div>
<div className="flex items-center justify-end gap-2">
<button onClick={() => onEdit(deck)} className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer" title="Edit">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
@ -150,7 +139,6 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCar
</div>
</div>
</div>
</div>
);
}
@ -322,6 +310,8 @@ export default function FlashcardsPage() {
if (!activeDeck) return;
let targetGroupId: string | null = null;
let targetIndex = 0;
const overContainerId = over.data.current?.sortable?.containerId;
if (overContainerId) {
targetGroupId = overContainerId === "uncategorized" ? null : overContainerId;
@ -350,7 +340,7 @@ export default function FlashcardsPage() {
}
const affectedGroups = new Set([activeDeck.groupId, targetGroupId]);
const updates: ReorderItem[] = [];
const updates: any[] = [];
affectedGroups.forEach(gId => {
const gItems = newItems.filter(d => d.groupId === gId);

View file

@ -12,8 +12,8 @@ export default async function ClassLayout(props: LayoutProps<"/[classSlug]">) {
return (
<div className="app-page">
{/* Dynamic class header */}
<ClassHeader className={classData.name} />
{/* Dynamic Header & Tabs */}
<ClassHeader classSlug={classSlug} className={classData.name} />
{/* Page content */}
{props.children}

View file

@ -27,7 +27,6 @@ import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { ReorderItem } from "@/types/study";
interface QuizItem {
id: string;
@ -36,12 +35,6 @@ interface QuizItem {
groupId: string | null;
sortOrder: number;
_count: { questions: number; attempts: number };
progress: Array<{
mode: string;
currentIndex: number;
orderJson: string;
answersJson: string | null;
}>;
}
interface MaterialGroup {
@ -52,14 +45,7 @@ interface MaterialGroup {
const cache: Record<string, { quizzes: QuizItem[]; groups: MaterialGroup[] }> = {};
interface SortableQuizCardProps {
quiz: QuizItem;
onEdit: (quiz: QuizItem) => void;
onDelete: (quiz: QuizItem) => void;
classSlug: string;
}
function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCardProps) {
function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: quiz.id });
const style = {
transform: CSS.Transform.toString(transform),
@ -93,36 +79,10 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCar
)}
</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 w-full min-w-0 gap-2 sm:flex-1">
{quiz.progress?.length ? (
<>
<Link href={`/${classSlug}/quizzes/${quiz.id}`} className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200">
Continue
</Link>
<button
onClick={async () => {
if (!confirm("Start over from the beginning? This will clear your current progress for this quiz.")) return;
await fetch("/api/progress", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: "QUIZ", contentId: quiz.id, mode: "SEQUENTIAL" }),
}).catch(() => {});
window.location.href = `/${classSlug}/quizzes/${quiz.id}`;
}}
className="flex-1 cursor-pointer rounded-lg border border-border bg-bg-surface-alt px-4 py-2 text-center text-sm font-medium text-text-heading shadow-sm transition-all duration-200 hover:bg-border"
title="Restart"
>
Restart
</button>
</>
) : (
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-border-light pl-7">
<Link href={`/${classSlug}/quizzes/${quiz.id}`} className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200">
Start Quiz
</Link>
)}
</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" />
@ -137,7 +97,6 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCar
</div>
</div>
</div>
</div>
);
}
@ -320,6 +279,8 @@ export default function QuizzesPage() {
if (!activeQuiz) return;
let targetGroupId: string | null = null;
let targetIndex = 0;
// Check if over a group container directly
const overContainerId = over.data.current?.sortable?.containerId;
if (overContainerId) {
@ -351,7 +312,7 @@ export default function QuizzesPage() {
// Re-calculate sortOrder for the affected groups to persist
const affectedGroups = new Set([activeQuiz.groupId, targetGroupId]);
const updates: ReorderItem[] = [];
const updates: any[] = [];
affectedGroups.forEach(gId => {
const gItems = newItems.filter(q => q.groupId === gId);

View file

@ -1,5 +0,0 @@
import { SpacedRepetitionViewer } from "@/components/spaced-repetition/SpacedRepetitionViewer";
export default function SpacedRepetitionStudyPage() {
return <SpacedRepetitionViewer />;
}

View file

@ -1,5 +0,0 @@
import { SpacedRepetitionSets } from "@/components/spaced-repetition/SpacedRepetitionSets";
export default function SpacedRepetitionPage() {
return <SpacedRepetitionSets />;
}

View file

@ -2,17 +2,12 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { ActivityBanner } from "@/components/activity/ActivityBanner";
interface ClassItem {
id: string;
slug: string;
name: string;
_count: { decks: number; quizSets: number };
spacedRepetitionDueCards: number;
spacedRepetitionLearningCards: number;
spacedRepetitionNewCards: number;
spacedRepetitionReadyCards: number;
}
const accents = ["#4f46e5", "#f97360", "#25845f", "#c27718", "#8b5cf6", "#0284c7"];
@ -61,9 +56,33 @@ export default function HomePage() {
} finally { setSavingEdit(false); }
}
const deckTotal = classes.reduce((sum, item) => sum + item._count.decks, 0);
const quizTotal = classes.reduce((sum, item) => sum + item._count.quizSets, 0);
return (
<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 && (
<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">
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1">{cls._count.decks} {cls._count.decks === 1 ? "deck" : "decks"}</span>
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1">{cls._count.quizSets} {cls._count.quizSets === 1 ? "quiz" : "quizzes"}</span>
{cls.spacedRepetitionReadyCards > 0 && (
<span
className="rounded-full bg-error-bg px-2.5 py-1 text-error"
title={`${cls.spacedRepetitionDueCards} due, ${cls.spacedRepetitionLearningCards} learning, ${cls.spacedRepetitionNewCards} new`}
>
{cls.spacedRepetitionReadyCards} {cls.spacedRepetitionReadyCards === 1 ? "card" : "cards"} ready
</span>
)}
</div>
</Link>
<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">

View file

@ -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 });
}

View file

@ -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;
}
}

View file

@ -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 }
);
}
}

View file

@ -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}`));
}

View file

@ -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 });
}

View file

@ -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;
}
}

View file

@ -1,29 +1,116 @@
import { NextRequest, NextResponse } from "next/server";
import { loginSchema } from "@/lib/validation/authSchemas";
import { prisma } from "@/lib/db";
import { createSession } from "@/lib/auth";
import { checkRateLimit } from "@/lib/rateLimiter";
import { login } from "@/services/authService";
// Progressive lockout thresholds from Section 4
function getLockoutDuration(failedAttempts: number): number {
if (failedAttempts >= 20) return 24 * 60 * 60 * 1000; // 24 hours
if (failedAttempts >= 15) return 30 * 60 * 1000; // 30 minutes
if (failedAttempts >= 10) return 5 * 60 * 1000; // 5 minutes
if (failedAttempts >= 5) return 1 * 60 * 1000; // 1 minute
return 0;
}
export async function POST(request: NextRequest) {
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
if (!checkRateLimit(`login:${ip}`).allowed) {
// Rate limit by IP
const forwarded = request.headers.get("x-forwarded-for");
const ip = forwarded?.split(",")[0]?.trim() ?? "unknown";
const rateCheck = checkRateLimit(ip);
if (!rateCheck.allowed) {
return NextResponse.json(
{ error: "Too many requests. Try again shortly." },
{ status: 429 }
);
}
const parsed = loginSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
const body = await request.json().catch(() => null);
if (!body?.password || typeof body.password !== "string") {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid request" },
{ error: "Password is required" },
{ status: 400 }
);
}
const result = await login(parsed.data.password);
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: result.status });
// Check lockout state
let security = await prisma.authSecurity.findUnique({ where: { id: 1 } });
if (!security) {
security = await prisma.authSecurity.create({ data: { id: 1 } });
}
if (security.lockedUntil && security.lockedUntil > new Date()) {
const remainingMs =
security.lockedUntil.getTime() - Date.now();
const remainingMin = Math.ceil(remainingMs / 60000);
return NextResponse.json(
{
error: `Account locked. Try again in ${remainingMin} minute${remainingMin !== 1 ? "s" : ""}.`,
},
{ status: 423 }
);
}
// Verify password
const setting = await prisma.setting.findUnique({
where: { key: "admin_password_hash" },
});
let isValid = false;
if (!setting) {
// Initial setup: hash and save the new password
const argon2 = await import("argon2");
const newHash = await argon2.hash(body.password);
await prisma.setting.create({
data: {
key: "admin_password_hash",
value: newHash,
},
});
isValid = true;
} else {
// Verify existing password
const passwordHash = setting.value;
try {
const argon2 = await import("argon2");
isValid = await argon2.verify(passwordHash, body.password);
} catch {
isValid = body.password === passwordHash;
}
}
if (!isValid) {
const newFailedAttempts = security.failedAttempts + 1;
const lockoutMs = getLockoutDuration(newFailedAttempts);
const lockedUntil = lockoutMs > 0 ? new Date(Date.now() + lockoutMs) : null;
await prisma.authSecurity.update({
where: { id: 1 },
data: {
failedAttempts: newFailedAttempts,
lockedUntil,
lastAttemptAt: new Date(),
},
});
return NextResponse.json(
{ error: "Invalid password" },
{ status: 401 }
);
}
// Success — reset lockout, create session
await prisma.authSecurity.update({
where: { id: 1 },
data: {
failedAttempts: 0,
lockedUntil: null,
lastAttemptAt: new Date(),
},
});
await createSession();
return NextResponse.json({ success: true });
}

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -1,20 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { reorderRequestSchema } from "@/lib/validation/reorderSchemas";
export async function PATCH(request: NextRequest) {
try {
const parsed = reorderRequestSchema.safeParse(await request.json());
if (!parsed.success) {
const body = await request.json();
const { items } = body;
if (!Array.isArray(items)) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const { items } = parsed.data;
// items should be an array of { id, sortOrder, groupId }
// Using a transaction to perform all updates
await prisma.$transaction(
items.map((item) =>
items.map((item: any) =>
prisma.deck.update({
where: { id: item.id },
data: {

View file

@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import type { Prisma } from "@/generated/prisma/client";
export async function PATCH(
request: NextRequest,
@ -11,7 +10,7 @@ export async function PATCH(
const body = await request.json();
const { name, sortOrder } = body;
const updateData: Prisma.MaterialGroupUpdateInput = {};
const updateData: any = {};
if (name !== undefined) updateData.name = name;
if (sortOrder !== undefined) updateData.sortOrder = sortOrder;

View file

@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import type { Prisma } from "@/generated/prisma/client";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
@ -15,7 +14,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Invalid type" }, { status: 400 });
}
const whereClause: Prisma.MaterialGroupWhereInput = { classId };
const whereClause: any = { classId };
if (type) {
whereClause.type = type;
}

View file

@ -1,20 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { reorderRequestSchema } from "@/lib/validation/reorderSchemas";
export async function PATCH(request: NextRequest) {
try {
const parsed = reorderRequestSchema.safeParse(await request.json());
if (!parsed.success) {
const body = await request.json();
const { items } = body;
if (!Array.isArray(items)) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const { items } = parsed.data;
// items should be an array of { id, sortOrder, groupId }
// Using a transaction to perform all updates
await prisma.$transaction(
items.map((item) =>
items.map((item: any) =>
prisma.quizSet.update({
where: { id: item.id },
data: {

View file

@ -1,21 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import * as settingsService from "@/services/settingsService";
function getInstructionType(request: NextRequest): settingsService.LlmInstructionType | null {
const type = new URL(request.url).searchParams.get("type") ?? "flashcards";
return type === "flashcards" || type === "quizzes" || type === "connections" || type === "crossword" ? type : null;
}
export async function GET(request: NextRequest) {
const type = getInstructionType(request);
if (!type) return NextResponse.json({ error: "Invalid instruction type" }, { status: 400 });
const { searchParams } = new URL(request.url);
const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
const instructions = await settingsService.getLlmInstructions(type);
return NextResponse.json({ value: instructions });
}
export async function PATCH(request: NextRequest) {
const type = getInstructionType(request);
if (!type) return NextResponse.json({ error: "Invalid instruction type" }, { status: 400 });
const { searchParams } = new URL(request.url);
const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
const body = await request.json().catch(() => null);
if (!body || typeof body.value !== "string") {

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -170,537 +170,6 @@
.animate-slide-up { animation: slideUp .32s var(--ease-spring); }
.animate-subtle-pulse { animation: subtlePulse 1.7s ease-in-out infinite; }
/* Arcade routes take over the complete application shell, including navigation. */
body:has(.arcade-route) {
--theme-bg-base: #070b18;
--theme-bg-surface: #11182a;
--theme-bg-surface-alt: #19233a;
--theme-bg-callout: #282357;
--theme-primary: #9a8cff;
--theme-primary-hover: #b5aaff;
--theme-text-heading: #f9f7ff;
--theme-text-body: #dbe2f4;
--theme-text-secondary: #aab6d1;
--theme-text-muted: #71809e;
--theme-border: #34415c;
--theme-border-light: #242f46;
min-height: 100vh;
background:
radial-gradient(circle at 76% 4%, rgba(116, 91, 255, .2), transparent 28rem),
radial-gradient(circle at 30% 92%, rgba(5, 198, 181, .11), transparent 34rem),
linear-gradient(145deg, #080c19, #0c1221 58%, #0b1020);
}
body:has(.arcade-route)::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
opacity: .11;
background-image: radial-gradient(rgba(201, 211, 255, .7) .7px, transparent .7px);
background-size: 22px 22px;
}
body:has(.arcade-route) main { min-height: 100vh; }
body:has(.arcade-route) .app-page { max-width: none; min-height: 100vh; }
body:has(.arcade-route) .arcade-route { width: min(100%, 1360px); margin-inline: auto; }
body:has(.arcade-route) .app-sidebar,
body:has(.arcade-route) .app-mobile-header {
border-color: rgba(138, 157, 201, .15);
background: rgba(8, 13, 27, .9);
box-shadow: 18px 0 55px rgba(0, 0, 0, .16);
}
body:has(.connections-world) {
background:
radial-gradient(circle at 28% 0%, rgba(88, 101, 242, .34), transparent 36rem),
radial-gradient(circle at 96% 28%, rgba(239, 71, 111, .16), transparent 30rem),
linear-gradient(145deg, #0b1329, #0e1932 54%, #142440);
}
.arcade-lobby-heading { position: relative; }
.arcade-lobby-heading::after {
content: "SELECT GAME";
position: absolute;
right: 0;
top: .5rem;
color: rgba(154, 140, 255, .08);
font-size: clamp(3rem, 8vw, 7rem);
font-weight: 950;
letter-spacing: -.06em;
line-height: .8;
pointer-events: none;
}
.arcade-cabinet {
position: relative;
display: flex;
min-height: 30rem;
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(148, 163, 202, .2);
border-radius: 1.75rem;
background: #11182a;
box-shadow: inset 0 1px rgba(255,255,255,.06), 0 22px 55px rgba(0,0,0,.28);
transition: transform .24s var(--ease-spring), box-shadow .24s ease, border-color .24s ease;
}
.arcade-cabinet.is-playable:hover { transform: translateY(-7px) rotate(-.3deg); border-color: rgba(181,170,255,.65); box-shadow: 0 30px 70px rgba(0,0,0,.38), 0 0 45px rgba(126,103,255,.13); }
.arcade-cabinet.is-coming { filter: saturate(.82); }
.arcade-visual { position: relative; height: 13.5rem; overflow: hidden; border-bottom: 1px solid rgba(255,255,255,.1); }
.arcade-cabinet-copy { display: flex; flex: 1; flex-direction: column; padding: 1.25rem; }
.arcade-cabinet-status { font-size: .65rem; font-weight: 900; letter-spacing: .18em; text-transform: uppercase; }
.arcade-cabinet-copy h3 { margin-top: .35rem; color: white; font-size: 1.55rem; font-weight: 900; letter-spacing: -.04em; }
.arcade-cabinet-copy > p:not(.arcade-cabinet-status) { margin-top: .55rem; flex: 1; color: #aab6d1; font-size: .86rem; line-height: 1.65; }
.arcade-cabinet-action { display: grid; min-height: 2.9rem; margin-top: 1.1rem; place-items: center; border: 1px solid rgba(255,255,255,.13); border-radius: .9rem; color: #8d9ab5; background: rgba(5,9,20,.3); font-size: .8rem; font-weight: 850; }
.is-playable .arcade-cabinet-action { color: #14152a; border-color: transparent; background: linear-gradient(135deg, #a89dff, #8170f5); box-shadow: 0 10px 28px rgba(126,103,255,.28); }
.connections-visual { display: grid; place-items: center; background: radial-gradient(circle at 50% 20%, rgba(180,162,255,.35), transparent 55%), linear-gradient(145deg,#342c64,#1d2042); }
.connections-visual-grid { display: grid; width: 9.5rem; grid-template-columns: repeat(4, 1fr); gap: .38rem; transform: perspective(400px) rotateX(8deg) rotateZ(-2deg); }
.connections-visual-grid i { aspect-ratio: 1; border-radius: .5rem; box-shadow: inset 0 1px rgba(255,255,255,.45), 0 5px 10px rgba(0,0,0,.22); }
.connections-visual-grid i:nth-child(-n+4) { background: #ffd166; }
.connections-visual-grid i:nth-child(n+5):nth-child(-n+8) { background: #5ee0a0; }
.connections-visual-grid i:nth-child(n+9):nth-child(-n+12) { background: #5ac8fa; }
.connections-visual-grid i:nth-child(n+13) { background: #b69cff; }
.connections-visual-label { position: absolute; right: 1rem; bottom: .75rem; color: rgba(255,255,255,.6); font-size: .62rem; font-weight: 900; letter-spacing: .16em; text-transform: uppercase; }
.crossword-visual { display: grid; place-items: center; background: linear-gradient(150deg,#f4dca1,#c98c45); }
.crossword-visual::before { content: ""; position: absolute; inset: 0; opacity: .12; background-image: linear-gradient(#412911 1px,transparent 1px),linear-gradient(90deg,#412911 1px,transparent 1px); background-size: 18px 18px; }
.crossword-grid { display: grid; width: 10rem; grid-template-columns: repeat(8,1fr); gap: 2px; transform: rotate(-3deg); filter: drop-shadow(0 12px 12px rgba(71,38,10,.28)); }
.crossword-grid i { display: grid; aspect-ratio: 1; place-items: center; background: #312419; color: transparent; font-size: .62rem; font-style: normal; font-weight: 950; }
.crossword-grid i.has-letter { color: #322112; background: #fff9e9; }
.crossword-pencil { position: absolute; right: 1.2rem; bottom: .55rem; color: #6d3e17; font-size: 3rem; transform: rotate(-24deg); text-shadow: 0 5px 8px rgba(64,35,10,.2); }
.arcade-cabinet-crossword .arcade-cabinet-status { color: #f3bb62; }
.blocks-visual { background: linear-gradient(#071a20,#0a3436); }
.blocks-visual::before { content: ""; position: absolute; inset: 0; opacity: .18; background-image: linear-gradient(rgba(74,222,128,.45) 1px,transparent 1px),linear-gradient(90deg,rgba(74,222,128,.45) 1px,transparent 1px); background-size: 24px 24px; }
.blocks-piece { position: absolute; display: grid; gap: 3px; }
.blocks-piece i,.blocks-floor i { border: 1px solid rgba(255,255,255,.35); border-radius: 4px; box-shadow: inset 0 0 8px rgba(255,255,255,.25),0 0 13px currentColor; }
.blocks-piece-a { left: 32%; top: 18%; grid-template-columns: repeat(2,1.8rem); color: #38e6c5; animation: arcadeBlockHover 2s ease-in-out infinite; }
.blocks-piece-a i { width: 1.8rem; height: 1.8rem; background: #0fcbaa; }
.blocks-piece-b { right: 18%; top: 42%; grid-template-columns: repeat(3,1.55rem); color: #ff4f91; transform: rotate(90deg); }
.blocks-piece-b i { width: 1.55rem; height: 1.55rem; background: #ed397e; }
.blocks-floor { position: absolute; right: 12%; bottom: 1rem; left: 12%; display: grid; grid-template-columns: repeat(6,1fr); gap: 3px; }
.blocks-floor i { aspect-ratio: 1; color: #ffc857; background: #eaaa2e; }
.blocks-floor i:nth-child(3n) { color: #6c7cff; background: #5868ee; transform: translateY(-1.5rem); }
.blocks-arrow { position: absolute; left: 19%; top: 20%; color: rgba(103,232,211,.5); font-size: 2.2rem; animation: arcadeArrowDown 1.3s ease-in-out infinite; }
.arcade-cabinet-falling-blocks .arcade-cabinet-status { color: #49ddb6; }
.asteroid-visual { background: radial-gradient(circle at 72% 28%,#253c74,#101735 45%,#070b19); }
.arcade-star { position: absolute; width: 3px; height: 3px; border-radius: 50%; background: white; box-shadow: 0 0 8px white; }
.star-a { left: 16%; top: 18%; }.star-b { right: 18%; top: 12%; }.star-c { left: 48%; top: 39%; }
.asteroid { position: absolute; border: 2px solid #8c91a6; border-radius: 47% 53% 42% 58%; background: radial-gradient(circle at 32% 28%,#9299ac,#4c5268 62%,#292e41); box-shadow: inset -7px -8px 12px rgba(0,0,0,.35),0 8px 18px rgba(0,0,0,.35); }
.asteroid::after { content: ""; position: absolute; width: 28%; height: 24%; left: 18%; top: 22%; border-radius: 50%; background: rgba(32,37,55,.45); }
.asteroid-a { width: 3.2rem; height: 3rem; right: 14%; top: 18%; transform: rotate(18deg); }.asteroid-b { width: 2rem; height: 2rem; left: 16%; top: 27%; }.asteroid-c { width: 1.4rem; height: 1.35rem; right: 32%; bottom: 18%; }
.space-station { position: absolute; left: 20%; bottom: 18%; width: 4rem; height: 1.4rem; border-radius: 65% 20% 35% 65%; background: linear-gradient(90deg,#b9c8ef,#6f83bd); transform: rotate(-12deg); box-shadow: 0 0 20px rgba(105,151,255,.35); }
.space-station::before { content: ""; position: absolute; left: 1.2rem; bottom: 1rem; border-right: 1rem solid transparent; border-bottom: 1.5rem solid #53699f; border-left: .25rem solid transparent; }
.space-station i { position: absolute; right: -.75rem; top: .3rem; width: 1rem; height: .8rem; border-radius: 50%; background: #50e6ff; box-shadow: 0 0 16px #4fdff8; }
.laser-beam { position: absolute; width: 38%; height: 2px; left: 42%; top: 52%; background: linear-gradient(90deg,#70f4ff,transparent); transform: rotate(-20deg); transform-origin: left; box-shadow: 0 0 8px #52e7ff; }
.shield-ring { position: absolute; left: 8%; bottom: 4%; width: 7.5rem; height: 6rem; border: 2px solid rgba(90,221,255,.25); border-radius: 50%; transform: rotate(-15deg); }
.arcade-cabinet-asteroid-defense .arcade-cabinet-status { color: #65c9ff; }
@keyframes arcadeBlockHover { 0%,100% { transform: translateY(0); } 50% { transform: translateY(8px); } }
@keyframes arcadeArrowDown { 0%,100% { opacity: .3; transform: translateY(-5px); } 50% { opacity: .8; transform: translateY(6px); } }
/* Connections has its own playful game-show visual system, independent of the desk UI. */
.connections-world {
--theme-bg-surface: #182443;
--theme-bg-surface-alt: #223154;
--theme-bg-callout: #2c3d68;
--theme-primary: #ffd166;
--theme-primary-hover: #ffe29a;
--theme-text-heading: #f8fbff;
--theme-text-body: #dbe7ff;
--theme-text-secondary: #afc0df;
--theme-text-muted: #8497bd;
--theme-border: #52668d;
--theme-border-light: #33486f;
position: relative;
isolation: isolate;
overflow: hidden;
color: var(--theme-text-body);
background:
radial-gradient(circle at 10% 0%, rgba(88, 101, 242, .45), transparent 28rem),
radial-gradient(circle at 95% 25%, rgba(239, 71, 111, .2), transparent 25rem),
linear-gradient(145deg, #101a35 0%, #111d39 48%, #172846 100%);
box-shadow: 0 28px 80px rgba(8, 15, 34, .28);
}
.connections-hub.connections-world,
.connections-stage.connections-world {
overflow: visible;
background: transparent;
box-shadow: none;
}
.connections-hub.connections-world::before,
.connections-stage.connections-world::before { display: none; }
.connections-world::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
opacity: .13;
background-image:
linear-gradient(rgba(255,255,255,.18) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.18) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: linear-gradient(to bottom, black, transparent 78%);
}
.connections-world .editorial-title {
font-family: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
font-weight: 850;
letter-spacing: -.045em;
}
.connections-hub-hero,
.connections-topbar,
.connections-status,
.connections-setup,
.connections-import {
backdrop-filter: blur(18px);
box-shadow: inset 0 1px rgba(255,255,255,.08), 0 18px 45px rgba(2, 8, 23, .22);
}
.connections-hub-hero {
background: linear-gradient(120deg, rgba(34,49,84,.94), rgba(24,36,67,.72));
border: 1px solid rgba(151, 174, 221, .18);
}
.connections-pack-card {
border-color: rgba(117, 141, 187, .28);
background: rgba(24, 36, 67, .74);
box-shadow: inset 0 1px rgba(255,255,255,.05), 0 12px 30px rgba(3, 9, 24, .16);
}
.connections-pack-card:hover {
transform: translateY(-2px);
border-color: rgba(255, 209, 102, .48);
}
.connections-pack-card.is-active {
border-color: #ffd166;
background: linear-gradient(120deg, rgba(65, 72, 132, .9), rgba(38, 55, 96, .9));
box-shadow: 0 0 0 1px rgba(255,209,102,.25), 0 18px 45px rgba(3,9,24,.25);
}
.connections-primary-button {
color: #172033;
background: linear-gradient(135deg, #ffd166, #ffb84d);
box-shadow: 0 10px 26px rgba(255, 184, 77, .24), inset 0 1px rgba(255,255,255,.5);
transition: transform .18s var(--ease-spring), filter .18s ease, box-shadow .18s ease;
}
.connections-primary-button:not(:disabled):hover {
transform: translateY(-2px) scale(1.01);
filter: brightness(1.06);
box-shadow: 0 14px 34px rgba(255, 184, 77, .32), inset 0 1px rgba(255,255,255,.6);
}
.connections-secondary-button {
color: #dbe7ff;
border: 1px solid #52668d;
background: rgba(24, 36, 67, .82);
transition: transform .18s var(--ease-spring), background .18s ease;
}
.connections-secondary-button:not(:disabled):hover { transform: translateY(-2px); background: #2c3d68; }
.connections-tile {
color: #15213a;
border: 1px solid rgba(255,255,255,.75);
background: linear-gradient(145deg, #fffaf0, #e9effa);
box-shadow: 0 7px 0 #aebbd2, 0 12px 24px rgba(2,8,23,.24), inset 0 1px #fff;
animation: connectionsTileIn .36s var(--ease-spring) both;
transition: transform .16s var(--ease-spring), box-shadow .16s ease, color .16s ease, background .16s ease;
}
.connections-tile:hover { transform: translateY(-3px); box-shadow: 0 9px 0 #aebbd2, 0 16px 30px rgba(2,8,23,.28), inset 0 1px #fff; }
.connections-tile.is-selected {
color: #fff;
border-color: #8c7cf7;
background: linear-gradient(145deg, #735df2, #5540ca);
box-shadow: 0 5px 0 #30228e, 0 12px 28px rgba(84,64,202,.4), inset 0 1px rgba(255,255,255,.28);
transform: translateY(2px) scale(.975);
animation: connectionsTileSelect .25s var(--ease-spring);
}
.connections-group,
.connections-review-card {
color: #172033;
box-shadow: inset 0 1px rgba(255,255,255,.55), 0 10px 26px rgba(2,8,23,.2);
}
.connections-group .text-text-heading,
.connections-group .text-text-secondary,
.connections-review-card .text-text-heading,
.connections-review-card .text-text-secondary { color: #172033; }
.connections-group { animation: connectionsGroupReveal .56s var(--ease-spring) both; }
.connections-group-0 { background: linear-gradient(135deg, #ffd166, #f7b944); }
.connections-group-1 { background: linear-gradient(135deg, #70e1a1, #36bd7c); }
.connections-group-2 { background: linear-gradient(135deg, #72d5f7, #4aa8e8); }
.connections-group-3 { background: linear-gradient(135deg, #b9a2ff, #8d73ed); }
.connections-results-hero {
background: linear-gradient(130deg, #5b46d8, #283d77 58%, #164e63);
box-shadow: inset 0 1px rgba(255,255,255,.18), 0 22px 50px rgba(2,8,23,.3);
}
.connections-completion {
position: relative;
display: grid;
min-height: 32rem;
place-content: center;
overflow: hidden;
border-radius: 1.75rem;
text-align: center;
background: radial-gradient(circle, rgba(94,234,212,.2), transparent 42%), rgba(10,18,39,.72);
animation: connectionsCompletionIn .55s var(--ease-spring) both;
}
.connections-completion-mark {
display: grid;
width: 6rem;
height: 6rem;
margin: 0 auto 1.5rem;
place-items: center;
border-radius: 999px;
color: #172033;
background: #ffd166;
box-shadow: 0 0 0 12px rgba(255,209,102,.12), 0 0 65px rgba(255,209,102,.46);
font-size: 3rem;
font-weight: 900;
animation: connectionsWinMark .8s .15s var(--ease-spring) both;
}
.connections-completion p { color: #8ee7d1; font-size: .75rem; font-weight: 850; letter-spacing: .2em; text-transform: uppercase; }
.connections-completion h2 { margin-top: .35rem; color: white; font-size: clamp(2.5rem, 8vw, 5.5rem); font-weight: 900; letter-spacing: -.065em; line-height: .95; }
.connections-completion span { margin-top: 1rem; color: #b9c8e5; font-weight: 700; }
.connections-confetti { position: absolute; inset: 0; pointer-events: none; }
.connections-confetti i {
position: absolute;
top: -8%;
left: var(--confetti-x);
width: 9px;
height: 17px;
border-radius: 2px;
background: var(--confetti-color);
animation: connectionsConfettiFall 1.45s var(--confetti-delay) cubic-bezier(.2,.7,.3,1) both;
}
@keyframes connectionsTileIn { from { opacity: 0; transform: translateY(16px) scale(.94); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes connectionsTileSelect { 0% { transform: scale(1); } 55% { transform: translateY(3px) scale(.94); } 100% { transform: translateY(2px) scale(.975); } }
@keyframes connectionsShake { 0%,100% { transform: translateX(0); } 20% { transform: translateX(-9px); } 40% { transform: translateX(8px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(3px); } }
@keyframes connectionsGroupReveal { from { opacity: 0; transform: scale(.9) translateY(18px); filter: brightness(1.6); } 65% { transform: scale(1.025) translateY(-2px); } to { opacity: 1; transform: scale(1) translateY(0); filter: brightness(1); } }
@keyframes connectionsCompletionIn { from { opacity: 0; transform: scale(.94); } to { opacity: 1; transform: scale(1); } }
@keyframes connectionsWinMark { from { opacity: 0; transform: scale(.2) rotate(-25deg); } 70% { transform: scale(1.12) rotate(4deg); } to { opacity: 1; transform: scale(1) rotate(0); } }
@keyframes connectionsConfettiFall { 0% { opacity: 0; transform: translateY(-1rem) rotate(0); } 10% { opacity: 1; } 100% { opacity: 1; transform: translateY(38rem) rotate(var(--confetti-spin)); } }
@keyframes connectionsResultsIn { from { opacity: 0; transform: translateY(22px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
.animate-connections-shake { animation: connectionsShake .42s ease-in-out; }
.animate-connections-results-in { animation: connectionsResultsIn .52s var(--ease-spring) both; }
/* Crossword turns the full arcade shell into a warm, ink-and-paper newsroom. */
body:has(.crossword-world) {
--theme-bg-base: #20170f;
--theme-bg-surface: #f6ecd2;
--theme-bg-surface-alt: #ead9b5;
--theme-bg-callout: #dfc795;
--theme-primary: #9a5a24;
--theme-primary-hover: #7b431b;
--theme-text-heading: #2d2117;
--theme-text-body: #493725;
--theme-text-secondary: #68523a;
--theme-text-muted: #8a7052;
--theme-border: #a9865d;
--theme-border-light: #cfb88e;
background:
radial-gradient(circle at 18% 8%, rgba(230, 181, 88, .18), transparent 28rem),
linear-gradient(135deg, #1d140d, #3c2818 55%, #21160e);
}
body:has(.crossword-world)::before {
opacity: .16;
background-image: repeating-linear-gradient(0deg, transparent 0 3px, rgba(255,236,195,.16) 4px);
}
body:has(.crossword-world) .app-sidebar,
body:has(.crossword-world) .app-mobile-header {
--theme-bg-surface-alt: #3a2819;
--theme-bg-callout: #e4cc98;
--theme-primary: #8a4f20;
--theme-text-heading: #fff3d8;
--theme-text-secondary: #d7c3a2;
--theme-text-muted: #aa9170;
--theme-border-light: #5b4028;
color: #f8ead0;
border-color: rgba(224, 190, 126, .22);
background: rgba(35, 23, 14, .94);
}
.crossword-world {
position: relative;
color: var(--theme-text-body);
}
.crossword-hub.crossword-world,
.crossword-stage.crossword-world { overflow: visible; }
.crossword-hero,
.crossword-setup,
.crossword-active-clue,
.crossword-clues,
.crossword-import,
.crossword-paper {
border: 1px solid rgba(100, 68, 35, .24);
background:
linear-gradient(rgba(255,255,255,.18), rgba(255,255,255,0)),
repeating-linear-gradient(0deg, rgba(83,54,24,.025) 0 1px, transparent 1px 4px),
#f4e7c9;
box-shadow: inset 0 1px rgba(255,255,255,.7), 0 18px 48px rgba(15,9,4,.25);
}
.crossword-hero { position: relative; overflow: hidden; }
.crossword-hero > * { position: relative; z-index: 1; }
.crossword-hero::after {
content: "DAILY STUDY";
position: absolute;
right: 1rem;
top: .35rem;
color: rgba(77,51,28,.07);
font-family: Georgia, serif;
font-size: clamp(2.75rem, 5vw, 5rem);
font-weight: 900;
letter-spacing: -.06em;
line-height: 1;
pointer-events: none;
}
.crossword-kicker,
.crossword-section-label {
color: #925623;
font-size: .68rem;
font-weight: 900;
letter-spacing: .18em;
text-transform: uppercase;
}
.crossword-section-label { display: block; margin-bottom: .7rem; color: var(--theme-text-muted); }
.crossword-primary {
color: #fff8e7;
background: linear-gradient(135deg, #a86429, #744018);
box-shadow: inset 0 1px rgba(255,255,255,.24), 0 7px 0 #4a2912, 0 12px 24px rgba(49,27,12,.22);
transition: transform .16s var(--ease-spring), filter .16s ease;
}
.crossword-primary:hover { transform: translateY(-2px); filter: brightness(1.08); }
.crossword-primary:active { transform: translateY(4px); box-shadow: inset 0 1px rgba(255,255,255,.2), 0 3px 0 #4a2912; }
.crossword-pack {
border-color: rgba(111,76,43,.25);
background: rgba(244,231,201,.92);
box-shadow: 0 10px 26px rgba(20,12,6,.16), inset 0 1px rgba(255,255,255,.65);
transition: transform .18s ease, border-color .18s ease;
}
.crossword-pack:hover { transform: translateY(-2px); border-color: #a86429; }
.crossword-pack.is-active { border-color: #8d511f; box-shadow: 0 0 0 2px rgba(168,100,41,.2), 0 14px 30px rgba(20,12,6,.2); }
.crossword-paper > div i { aspect-ratio: 1; border: 1px solid rgba(45,33,23,.3); }
.crossword-size {
color: var(--theme-text-secondary);
border: 1px solid var(--theme-border-light);
background: rgba(255,250,235,.48);
}
.crossword-size.is-active { color: #2d2117; border-color: #955720; background: #e4c993; box-shadow: inset 0 0 0 2px rgba(255,255,255,.35); }
.crossword-board-preview { overflow: hidden; border: 1px solid #c9aa78; border-radius: .9rem; background: rgba(255,250,235,.48); }
.crossword-preview-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .65rem .8rem; border-bottom: 1px solid #d5bd93; color: #5d4630; }
.crossword-preview-heading span { font-size: .68rem; font-weight: 900; letter-spacing: .14em; text-transform: uppercase; }
.crossword-preview-heading small { color: #8a7052; font-size: .68rem; }
.crossword-preview-canvas { display: grid; min-height: 9rem; place-items: center; overflow: hidden; padding: .75rem; background: #322116; }
.crossword-preview-grid { display: grid; filter: drop-shadow(0 6px 9px rgba(0,0,0,.35)); }
.crossword-preview-grid i { border: .5px solid #9e8058; background: #fff6de; }
.crossword-preview-loading,.crossword-preview-canvas > p { color: #d7c3a2; font-size: .75rem; font-weight: 800; }
.crossword-option { display: flex; align-items: flex-start; gap: .7rem; padding: .75rem; border-radius: .75rem; background: rgba(224,202,157,.38); }
.crossword-option input { margin-top: .2rem; accent-color: #925623; }
.crossword-option strong,.crossword-option small { display: block; }
.crossword-option small { margin-top: .12rem; color: var(--theme-text-muted); }
.crossword-active-clue { display: grid; gap: .2rem; }
.crossword-active-clue span { color: #925623; font-size: .68rem; font-weight: 900; letter-spacing: .15em; text-transform: uppercase; }
.crossword-active-clue strong { color: #2d2117; font-family: Georgia, serif; font-size: 1.05rem; }
.crossword-active-clue small { color: var(--theme-text-muted); }
.crossword-toolbar { color: #f4e7c9; background: rgba(35,23,14,.9); box-shadow: 0 8px 24px rgba(13,8,4,.24); }
.crossword-toolbar > div { display: flex; align-items: center; gap: .35rem; }
.crossword-toolbar button { display: grid; width: 2rem; height: 2rem; place-items: center; border: 1px solid rgba(245,222,179,.25); border-radius: .45rem; font-weight: 900; }
.crossword-toolbar span { min-width: 3rem; text-align: center; font-size: .75rem; font-weight: 800; }
.crossword-board-viewport {
max-height: min(68vh, 46rem);
overflow: auto;
padding: 1rem;
overscroll-behavior: contain;
background: #2a1d13;
box-shadow: inset 0 0 0 5px #4c321e, inset 0 0 28px rgba(0,0,0,.7), 0 16px 38px rgba(13,8,4,.3);
}
.crossword-board { display: grid; width: max-content; min-width: 100%; min-height: 20rem; place-content: center; }
.crossword-cell {
position: relative;
display: grid;
width: var(--crossword-cell);
height: var(--crossword-cell);
place-items: center;
color: #25190f;
border: 1px solid #6f5335;
background: #fff8e6;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
transition: background .12s ease, box-shadow .12s ease;
}
.crossword-cell small { position: absolute; left: 2px; top: 0; font-size: max(7px, calc(var(--crossword-cell) * .22)); line-height: 1; }
.crossword-cell strong { font-size: max(12px, calc(var(--crossword-cell) * .56)); }
.crossword-cell.is-word { background: #f2d99f; }
.crossword-cell.is-active { z-index: 2; background: #ffe27e; box-shadow: inset 0 0 0 3px #925623; }
.crossword-cell.is-wrong::after { content: ""; position: absolute; right: 3px; bottom: 3px; width: 7px; height: 7px; border: 2px solid #a62e25; border-radius: 50%; }
.crossword-cell.is-revealed strong { color: #8a5a24; text-decoration: underline dotted; }
.crossword-secondary {
min-height: 2.75rem;
padding: .6rem .9rem;
border: 1px solid #9f7a50;
border-radius: .7rem;
color: #392718;
background: #ead6ae;
font-size: .78rem;
font-weight: 850;
box-shadow: 0 3px 0 #aa895f;
}
.crossword-secondary:hover { background: #f5e5c4; }
.crossword-clues { max-height: 72vh; overflow: hidden; }
.crossword-clues > div button { min-height: 2.5rem; border-bottom: 2px solid transparent; color: var(--theme-text-muted); font-size: .75rem; font-weight: 900; text-transform: uppercase; }
.crossword-clues > div button.is-active { color: #7d461d; border-color: #9a5a24; }
.crossword-clues ol { max-height: calc(72vh - 5rem); overflow-y: auto; padding-right: .25rem; }
.crossword-clues li button { display: grid; width: 100%; grid-template-columns: 2rem 1fr; gap: .4rem; padding: .55rem; border-radius: .5rem; text-align: left; color: #4b3826; }
.crossword-clues li button:hover,.crossword-clues li button.is-active { background: #dec38c; }
.crossword-clues li b { color: #8a4f20; }
.crossword-mobile-input { position: fixed; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.crossword-results-hero { color: #f8ead0; background: linear-gradient(135deg, #3b2819, #6d421f); box-shadow: 0 18px 45px rgba(15,9,4,.3); }
.crossword-results-hero > div > div { background: rgba(255,244,220,.1); }
.crossword-results-hero small,.crossword-results-hero strong { display: block; }
.crossword-results-hero small { color: rgba(255,244,220,.6); font-size: .65rem; font-weight: 900; text-transform: uppercase; }
.crossword-results-hero strong { margin-top: .2rem; font-size: 1.25rem; }
.crossword-final-board { border: 1px solid #c8aa79; color: #392718; background: #f4e7c9; box-shadow: 0 12px 32px rgba(20,12,6,.18); }
.crossword-final-legend { display: flex; flex-wrap: wrap; gap: .8rem; color: #6a5139; font-size: .72rem; font-weight: 800; }
.crossword-final-legend span { display: inline-flex; align-items: center; gap: .35rem; }
.crossword-final-legend i { width: .8rem; height: .8rem; border: 1px solid currentColor; border-radius: .15rem; }
.crossword-final-legend i.is-correct { color: #356b3b; background: #bfe0b9; }
.crossword-final-legend i.is-assisted { color: #8a631c; background: #f2d47e; }
.crossword-final-legend i.is-incorrect { color: #923a30; background: #efb9af; }
.crossword-final-viewport { overflow: auto; padding: 1rem; border-radius: 1rem; background: #2a1d13; box-shadow: inset 0 0 22px rgba(0,0,0,.55); }
.crossword-final-grid { display: grid; width: max-content; min-width: 100%; place-content: center; }
.crossword-final-cell { position: relative; display: grid; place-items: center; color: #25190f; border: 1px solid #62482f; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.crossword-final-cell.is-correct { background: #c7e6bf; box-shadow: inset 0 0 0 1px rgba(47,108,58,.35); }
.crossword-final-cell.is-assisted { background: #f2d47e; box-shadow: inset 0 0 0 2px rgba(138,99,28,.42); }
.crossword-final-cell.is-incorrect { background: #efbbb1; box-shadow: inset 0 0 0 2px rgba(145,47,39,.45); }
.crossword-final-cell small { position: absolute; left: 2px; top: 1px; font-size: 7px; line-height: 1; }
.crossword-final-cell strong { font-size: clamp(10px, 2vw, 15px); }
.crossword-review { border: 1px solid #c8aa79; color: #392718; background: #f4e7c9; box-shadow: 0 8px 22px rgba(20,12,6,.15); }
.crossword-review.is-incorrect { border-left: 6px solid #a63a2e; }
.crossword-review.is-correct { border-left: 6px solid #4f7a4f; }
.crossword-review.is-assisted { border-left: 6px solid #b58627; }
.crossword-review.is-omitted { opacity: .75; border-left: 6px solid #8c765a; }
@media (max-width: 639px) {
.crossword-board-viewport { margin-inline: -.25rem; padding: .6rem; }
.crossword-board { place-content: start; min-height: 24rem; }
.crossword-clues { max-height: 28rem; }
.crossword-hero::after { top: .75rem; right: .75rem; font-size: 2.75rem; }
.crossword-final-viewport { margin-inline: -.25rem; padding: .65rem; }
}
.markdown-content h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: .5rem; color: var(--color-text-heading); }
.markdown-content h2 { font-size: 1.25rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }
.markdown-content h3 { font-size: 1.125rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }

View file

@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Manrope, Newsreader } from "next/font/google";
import Script from "next/script";
import "./globals.css";
const manrope = Manrope({
@ -25,12 +26,10 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" className={`${manrope.variable} ${newsreader.variable} h-full`} data-scroll-behavior="smooth" suppressHydrationWarning>
<head>
<script
id="theme-init"
dangerouslySetInnerHTML={{
__html: `
<html lang="en" className={`${manrope.variable} ${newsreader.variable} h-full`} suppressHydrationWarning>
<body className="min-h-full flex flex-col font-sans antialiased">
<Script id="theme-init" strategy="beforeInteractive">
{`
try {
const savedTheme = localStorage.theme
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
@ -40,11 +39,8 @@ export default function RootLayout({
document.documentElement.classList.remove('dark')
}
} catch (_) {}
`,
}}
/>
</head>
<body className="min-h-full flex flex-col font-sans antialiased" suppressHydrationWarning>
`}
</Script>
{children}
</body>
</html>

View file

@ -1,333 +1,158 @@
"use client";
import { useEffect, useState } from "react";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
type LoginStage = "login" | "token" | "password" | "success";
async function postJson(path: string, body?: object) {
const response = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {}),
});
const data: { error?: string } = await response.json();
if (!response.ok) throw new Error(data.error || "Request failed");
}
export default function LoginPage() {
const [stage, setStage] = useState<LoginStage>("login");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [token, setToken] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [setupRequired, setSetupRequired] = useState<boolean | null>(null);
const [isSetup, setIsSetup] = useState<boolean | null>(null);
const router = useRouter();
useEffect(() => {
fetch("/api/auth/setup-status")
.then((response) => response.json())
.then((data) => setSetupRequired(data.setupRequired))
.catch(() => setSetupRequired(false));
.then((res) => res.json())
.then((data) => setIsSetup(data.setupRequired))
.catch(() => setIsSetup(false)); // fallback
}, []);
async function run(action: () => Promise<void>) {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
await action();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Network error. Please try again.");
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || "Login failed");
return;
}
window.location.href = "/";
} catch {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
}
function handleLogin(event: React.FormEvent) {
event.preventDefault();
void run(async () => {
await postJson("/api/auth/login", { password });
window.location.href = "/";
});
}
function beginReset() {
void run(async () => {
await postJson("/api/auth/password-reset/request");
setToken("");
setStage("token");
});
}
function handleToken(event: React.FormEvent) {
event.preventDefault();
void run(async () => {
await postJson("/api/auth/password-reset/verify", { token: token.trim() });
setPassword("");
setConfirmPassword("");
setStage("password");
});
}
function handlePasswordReset(event: React.FormEvent) {
event.preventDefault();
void run(async () => {
await postJson("/api/auth/password-reset/complete", {
password,
confirmPassword,
});
setToken("");
setPassword("");
setConfirmPassword("");
setStage("success");
});
}
function returnToLogin() {
setStage("login");
setError("");
setToken("");
setPassword("");
setConfirmPassword("");
}
if (setupRequired === null) {
if (isSetup === null) {
return (
<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>
);
}
const title =
stage === "token"
? "Enter reset token"
: stage === "password"
? "Choose a new password"
: stage === "success"
? "Password updated"
: setupRequired
? "Welcome to Study Desk"
: "Welcome back";
const description =
stage === "token"
? "Find the token in the Study Desk Docker logs. It expires after 15 minutes."
: stage === "password"
? "Use at least eight characters and enter the password twice."
: stage === "success"
? "Your old password and existing sessions are no longer valid."
: setupRequired
? "Please set your admin password for the first time."
: "Open your notes, decks, and practice quizzes.";
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden p-4 sm:p-8">
<div className="absolute inset-0 paper-grid opacity-70" />
<div className="absolute -left-24 top-1/4 h-80 w-80 rounded-full bg-primary/15 blur-3xl" />
<div className="absolute -right-24 bottom-0 h-72 w-72 rounded-full bg-accent/15 blur-3xl" />
<div className="absolute right-4 top-4 z-10"><ThemeToggle /></div>
<main className="relative w-full max-w-md animate-fade-in">
<header className="mb-7 text-center">
<div className="relative w-full max-w-md animate-fade-in">
{/* Logo / Title area */}
<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>
<p className="text-xs font-bold uppercase tracking-[0.2em] text-primary">Personal workspace</p>
<h1 className="editorial-title mt-2 text-4xl text-text-heading">{title}</h1>
<p className="mt-1 text-sm text-text-muted">{description}</p>
</header>
<h1 className="editorial-title mt-2 text-4xl text-text-heading">
{isSetup ? "Welcome to Study Desk" : "Welcome back"}
</h1>
<p className="text-text-muted mt-1 text-sm">
{isSetup
? "Please set your admin password for the first time."
: "Open your notes, decks, and practice quizzes."}
</p>
</div>
{/* 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">
{stage === "login" && (
<form onSubmit={handleLogin} className="space-y-4">
<PasswordField
<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"
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>
</main>
</div>
);
}
function PasswordField({
id,
label,
value,
onChange,
placeholder,
describedBy,
invalid = false,
autoFocus = false,
}: {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
describedBy?: string;
invalid?: boolean;
autoFocus?: boolean;
}) {
return (
<div>
<label htmlFor={id} className="mb-1.5 block text-sm font-medium text-text-secondary">{label}</label>
<input
id={id}
type="password"
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
autoComplete={id === "password" ? "current-password" : "new-password"}
aria-describedby={describedBy}
aria-invalid={invalid}
autoFocus={autoFocus}
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>
);
}
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"}`}
{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"
>
{valid ? validText : invalidText}
</p>
);
}
<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>
)}
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"
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 ? "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}
{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>
);
}

View file

@ -4,9 +4,8 @@ import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import type { SharedGroupData } from "@/types/study";
export function SharedGroupViewer({ data }: { data: SharedGroupData }) {
export function SharedGroupViewer({ data }: { data: any }) {
const pathname = usePathname();
const router = useRouter();
@ -19,7 +18,7 @@ export function SharedGroupViewer({ data }: { data: SharedGroupData }) {
if (typeof window === 'undefined') return;
const newProgress: Record<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 saved = localStorage.getItem(key);
if (saved) {
@ -72,7 +71,7 @@ export function SharedGroupViewer({ data }: { data: SharedGroupData }) {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{items.map((item) => (
{items.map((item: any) => (
<div
key={item.id}
className="group flex flex-col bg-bg-surface border border-border-light rounded-xl shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300"

View file

@ -6,11 +6,10 @@ import { usePathname } from "next/navigation";
import { FlashcardViewer } from "@/components/flashcards/FlashcardViewer";
import { QuizViewer } from "@/components/quizzes/QuizViewer";
import { CardList } from "@/components/flashcards/CardList";
import type { SharedStudyItem } from "@/types/study";
interface SharedViewerProps {
type: "flashcards" | "quizzes";
data: SharedStudyItem;
type: string;
data: any;
groupMode?: boolean;
}
@ -37,7 +36,7 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp
const [showTopics, setShowTopics] = useState(false);
const categories = type === "quizzes" && data.questions
? Array.from(new Set(data.questions.map((q) => q.category.toUpperCase()))).join(" · ")
? Array.from(new Set(data.questions.map((q: any) => q.category.toUpperCase()))).join(" · ")
: "";
return (
@ -138,20 +137,18 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp
view === "study" ? (
<FlashcardViewer
key={restartKey}
cards={data.cards ?? []}
cards={data.cards}
deckId={data.id}
isShared={true}
/>
) : (
<CardList cards={data.cards ?? []} />
<CardList cards={data.cards} />
)
) : (
<QuizViewer
key={restartKey}
quiz={{
id: data.id,
name: data.name,
questions: data.questions ?? [],
...data,
progress: [], // Start fresh since it's shared
}}
retakeIds={null}

View file

@ -4,9 +4,6 @@ import { ThemeToggle } from "@/components/ui/ThemeToggle";
import Link from "next/link";
import { SharedViewer } from "./SharedViewer";
import { SharedGroupViewer } from "./SharedGroupViewer";
import type { SharedGroupData, SharedStudyItem } from "@/types/study";
type SharedContentType = "flashcards" | "quizzes";
interface SharedPageProps {
params: Promise<{
@ -51,17 +48,17 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
notFound();
}
let targetData: SharedStudyItem | SharedGroupData | null = null;
let targetType: SharedContentType = type === "flashcards" ? "flashcards" : "quizzes";
let targetData: any = null;
let targetType = type;
if (type === "groups" && link.group) {
if (itemId) {
// Find the specific item inside the group
if (link.group.type === "DECK") {
targetData = link.group.decks.find(d => d.id === itemId) ?? null;
targetData = link.group.decks.find(d => d.id === itemId);
targetType = "flashcards";
} else {
targetData = link.group.quizSets.find(q => q.id === itemId) ?? null;
targetData = link.group.quizSets.find(q => q.id === itemId);
targetType = "quizzes";
}
if (!targetData) notFound();
@ -72,8 +69,6 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
targetData = type === "flashcards" ? link.deck : link.quizSet;
}
if (!targetData) notFound();
return (
<div className="min-h-screen bg-bg-base flex flex-col">
{/* Read-only Header */}
@ -110,11 +105,11 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
<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">
{type === "groups" && !itemId ? (
<SharedGroupViewer data={targetData as SharedGroupData} />
<SharedGroupViewer data={targetData} />
) : (
<SharedViewer
type={targetType}
data={targetData as SharedStudyItem}
data={targetData}
groupMode={type === "groups"}
/>
)}

View file

@ -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`;
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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` : "Lets 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>;
}

View file

@ -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>
);
}

View file

@ -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>;
}

View file

@ -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>;
}

View file

@ -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;

View file

@ -3,7 +3,6 @@
import { useState, useEffect, useCallback, useRef } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { recordStudyActivity } from "@/lib/activityClient";
interface Card {
id: string;
@ -56,7 +55,6 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
);
const [swipeClass, setSwipeClass] = useState("");
const cardRef = useRef<HTMLDivElement>(null);
const gradingRef = useRef(false);
// Touch/drag state
const dragRef = useRef({ startX: 0, currentX: 0, isDragging: false });
@ -122,12 +120,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
// Grade the current card
const gradeCard = useCallback(
(grade: CardResult) => {
if (!currentCard || !hasFlippedOnce || gradingRef.current) return;
gradingRef.current = true;
if (!isShared) {
recordStudyActivity("FLASHCARD").catch(() => {});
}
if (!currentCard || !hasFlippedOnce) return;
setToastMessage(null);
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");
setTimeout(() => {
gradingRef.current = false;
setSwipeClass("");
setIsFlipped(false);
setHasFlippedOnce(false);
@ -152,7 +144,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
}
}, 350);
},
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled, isShared]
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled]
);
// Keyboard shortcuts
@ -244,7 +236,6 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
// Restart handlers
function restartFullSet() {
gradingRef.current = false;
let newOrder: string[];
if (isShuffled) {
newOrder = [...cards.map((c) => c.id)].sort(() => Math.random() - 0.5);

View file

@ -74,8 +74,8 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) {
}
onCreated();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to create deck");
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}

View file

@ -2,18 +2,12 @@
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 [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveResult, setSaveResult] = useState<"success" | "error" | null>(null);
const [copied, setCopied] = useState(false);
const [packCount, setPackCount] = useState(1);
const batchOverride = importType === "connections" && packCount > 1
? `TEMPORARY BATCH OVERRIDE:\nGenerate exactly ${packCount} distinct Connections packs. Return one raw JSON array containing exactly ${packCount} objects that each follow the schema below. Make the packs meaningfully different from one another. This override takes precedence over any later instruction to return one object.\n\n`
: "";
const displayedInstructions = `${batchOverride}${instructions}`;
useEffect(() => {
fetch(`/api/settings/llm-instructions?type=${importType}`)
@ -58,7 +52,7 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
}
async function handleCopy() {
await navigator.clipboard.writeText(displayedInstructions);
await navigator.clipboard.writeText(instructions);
setCopied(true);
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.
</p>
{importType === "connections" && (
<div className="rounded-xl border border-border bg-bg-surface-alt/60 p-4">
<div className="flex items-center justify-between gap-4">
<div>
<label htmlFor="connections-pack-count" className="text-sm font-bold text-text-heading">Number of game packs</label>
<p className="text-xs text-text-muted">This temporarily updates the copied prompt. Your saved instructions stay unchanged.</p>
</div>
<output htmlFor="connections-pack-count" className="grid h-11 min-w-12 place-items-center rounded-lg border border-border bg-bg-surface px-3 text-lg font-extrabold text-text-heading">{packCount}</output>
</div>
<input id="connections-pack-count" type="range" min={1} max={10} step={1} value={packCount} onChange={(event) => setPackCount(Number(event.target.value))} className="mt-4 w-full accent-primary" />
<div className="mt-1 flex justify-between text-[10px] font-bold text-text-muted" aria-hidden><span>1</span><span>5</span><span>10</span></div>
</div>
)}
<textarea
value={displayedInstructions}
onChange={(event) => setInstructions(batchOverride && event.target.value.startsWith(batchOverride) ? event.target.value.slice(batchOverride.length) : event.target.value)}
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
rows={14}
className="w-full px-4 py-3 rounded-lg border border-border bg-bg-surface-alt/50 text-sm text-text-body font-mono leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200 resize-y"
/>

View file

@ -2,16 +2,15 @@
import { useState } from "react";
import { QuizResults } from "./QuizResults";
import type { QuizAttempt, QuizSummary } from "@/types/study";
interface AttemptHistoryProps {
quiz: QuizSummary;
attempts: QuizAttempt[];
quiz: any;
attempts: any[];
onRetake: (missedIds: string[]) => void;
}
export function AttemptHistory({ quiz, attempts, onRetake }: AttemptHistoryProps) {
const [selectedAttempt, setSelectedAttempt] = useState<QuizAttempt | null>(null);
const [selectedAttempt, setSelectedAttempt] = useState<any | null>(null);
if (selectedAttempt) {
return (

View file

@ -2,20 +2,20 @@
import { Fragment } from "react";
import { scoreQuestion } from "@/lib/scoring";
import type { QuizSummary } from "@/types/study";
interface CategoryBreakdownProps {
quiz: QuizSummary;
quiz: any;
attempt: any;
answeredIds: string[];
answers: Record<string, string[]>;
}
export function CategoryBreakdown({ quiz, answeredIds, answers }: CategoryBreakdownProps) {
export function CategoryBreakdown({ quiz, attempt, answeredIds, answers }: CategoryBreakdownProps) {
// Aggregate stats per category
const stats: Record<string, { earned: number; possible: number }> = {};
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;
const cat = q.category.trim().toLowerCase();
@ -23,11 +23,7 @@ export function CategoryBreakdown({ quiz, answeredIds, answers }: CategoryBreakd
stats[cat] = { earned: 0, possible: 0 };
}
const formattedQ = {
id: q.id,
type: q.type as "MULTIPLE_CHOICE" | "SATA",
options: q.options,
};
const formattedQ = { id: q.id, type: q.type, options: q.options };
const points = scoreQuestion(formattedQ, answers[q.id] || []);
stats[cat].earned += points;

View file

@ -4,21 +4,14 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { CategoryBreakdown } from "./CategoryBreakdown";
import { scoreQuestion } from "@/lib/scoring";
import type { QuizAttempt, QuizQuestion, QuizSummary } from "@/types/study";
interface QuizResultsProps {
quiz: QuizSummary;
attempt: QuizAttempt;
quiz: any; // QuizData
attempt: any; // QuizAttempt
onRetake: (missedIds: string[]) => void;
onClose?: () => void;
}
interface ReviewItem {
question: QuizQuestion;
score: number;
selections: string[];
}
export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsProps) {
let answers: Record<string, string[]> = {};
try {
@ -26,16 +19,16 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
} catch {}
// Determine non-full-credit questions for review list
const reviewItems: ReviewItem[] = [];
const reviewItems: any[] = [];
const missedIds: string[] = [];
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 = {
id: q.id,
type: q.type as "MULTIPLE_CHOICE" | "SATA",
type: q.type,
options: q.options,
};
const score = scoreQuestion(formattedQ, answers[q.id] || []);
@ -99,7 +92,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
<button
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);
}}
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 */}
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-6 md:p-8">
<h3 className="text-lg font-bold text-text-heading mb-6">Category Breakdown</h3>
<CategoryBreakdown quiz={quiz} answeredIds={answeredIds} answers={answers} />
<CategoryBreakdown quiz={quiz} attempt={attempt} answeredIds={answeredIds} answers={answers} />
</div>
{/* Review List */}
@ -120,7 +113,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
<div className="space-y-6">
<h3 className="text-xl font-bold text-text-heading px-2">Areas for Review</h3>
{reviewItems.map((item) => {
{reviewItems.map((item, idx) => {
const q = item.question;
const sels = item.selections;
return (
@ -135,7 +128,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
</div>
<div className="space-y-2 mb-6">
{q.options.map((opt) => {
{q.options.map((opt: any) => {
const isSelected = sels.includes(opt.id);
let stateClass = "cursor-default opacity-80";
let icon = null;

View file

@ -1,12 +1,10 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { scoreQuestion } from "@/lib/scoring";
import { QuizResults } from "./QuizResults";
import { recordStudyActivity } from "@/lib/activityClient";
import type { QuizAttempt } from "@/types/study";
interface Option {
id: string;
@ -41,7 +39,6 @@ interface QuizViewerProps {
}
export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: QuizViewerProps) {
const submittingQuestionRef = useRef<string | null>(null);
const [order, setOrder] = useState<string[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [answers, setAnswers] = useState<Record<string, string[]>>({});
@ -49,7 +46,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
const [shuffledOptions, setShuffledOptions] = useState<Record<string, Option[]>>({});
const [loading, setLoading] = useState(true);
const [resultsData, setResultsData] = useState<QuizAttempt | null>(null);
const [resultsData, setResultsData] = useState<any | null>(null);
const [toastMessage, setToastMessage] = useState<string | null>(null);
// Auto-hide toast
@ -197,11 +194,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
}
function handleSubmit() {
if (isSubmitted || !currentQId || submittingQuestionRef.current === currentQId) return;
submittingQuestionRef.current = currentQId;
if (!isShared) {
recordStudyActivity("QUIZ_QUESTION").catch(() => {});
}
if (isSubmitted || !currentQId) return;
const newSubmitted = [...submittedAnswers, currentQId];
setSubmittedAnswers(newSubmitted);
saveProgress(currentIndex, answers);
@ -209,7 +202,6 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
function handleNext() {
if (currentIndex + 1 < order.length) {
submittingQuestionRef.current = null;
const nextIdx = currentIndex + 1;
setCurrentIndex(nextIdx);
saveProgress(nextIdx, answers);
@ -298,7 +290,6 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
setCurrentIndex(0);
setAnswers({});
setSubmittedAnswers([]);
submittingQuestionRef.current = null;
setResultsData(null);
// Re-shuffle options for the new attempt
@ -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">
{/* Tags */}
<div className="mb-8 flex items-start justify-between gap-3">
<span className="inline-flex w-fit min-w-0 max-w-[48%] px-3 py-1 rounded-md bg-primary/10 text-primary text-xs font-bold uppercase tracking-wider leading-snug whitespace-normal">
<div className="flex items-center justify-between mb-8">
<span className="inline-flex px-3 py-1 rounded-md bg-primary/10 text-primary text-xs font-bold uppercase tracking-wider">
{currentQ.category}
</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"}
</span>
</div>
@ -383,7 +374,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
} else if (!isSelected && opt.isCorrect) {
stateClass = "border-success/50 bg-success/5 text-text-heading";
rightIcon = (
<div className="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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
@ -401,7 +392,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
<div
key={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={`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}
</div>
{rightIcon && (
<div className="col-start-2 min-w-0 md:flex-shrink-0 md:pl-2">
<div className="flex-shrink-0 pl-2">
{rightIcon}
</div>
)}

View file

@ -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>
);
}

View file

@ -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">Youre caught up</h2>
<p className="mt-2 text-text-secondary">There are no due cards and todays 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 14 based on how well you remembered" : "Space to flip"}</p>
</div>
)}
</div>
);
}

View file

@ -2,12 +2,14 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ClassTabs } from "./ClassTabs";
interface ClassHeaderProps {
classSlug: string;
className: string;
}
export function ClassHeader({ className }: ClassHeaderProps) {
export function ClassHeader({ classSlug, className }: ClassHeaderProps) {
const pathname = usePathname();
// If we are deeper than /[classSlug]/[tab], hide this header to save space
@ -21,7 +23,8 @@ export function ClassHeader({ className }: ClassHeaderProps) {
}
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>
<Link
href="/"
@ -37,5 +40,8 @@ export function ClassHeader({ className }: ClassHeaderProps) {
</div>
<p className="max-w-sm text-sm leading-6 text-text-secondary">Choose a mode, then settle in for a focused study session.</p>
</div>
<ClassTabs classSlug={classSlug} />
</>
);
}

View file

@ -1,10 +1,9 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import Link from "next/link";
import { ThemeToggle } from "./ThemeToggle";
import { STUDY_MODES } from "@/config/studyModes";
function Mark() {
return (
@ -20,37 +19,6 @@ export function Navbar() {
const [open, setOpen] = useState(false);
const parts = pathname.split("/").filter(Boolean);
const classSlug = parts[0] && parts[0] !== "shared" ? parts[0] : null;
const [hasReadyCards, setHasReadyCards] = useState(false);
const refreshDueCards = useCallback(async () => {
if (!classSlug) return;
try {
const response = await fetch("/api/classes");
if (!response.ok) return;
const classes = await response.json() as Array<{
slug: string;
spacedRepetitionReadyCards: number;
}>;
const currentClass = classes.find((classItem) => classItem.slug === classSlug);
setHasReadyCards((currentClass?.spacedRepetitionReadyCards ?? 0) > 0);
} catch {
// Keep the last known state when a background refresh fails.
}
}, [classSlug]);
useEffect(() => {
const initialRefresh = window.setTimeout(() => void refreshDueCards(), 0);
const interval = window.setInterval(refreshDueCards, 60_000);
window.addEventListener("focus", refreshDueCards);
window.addEventListener("spaced-repetition-updated", refreshDueCards);
return () => {
window.clearTimeout(initialRefresh);
window.clearInterval(interval);
window.removeEventListener("focus", refreshDueCards);
window.removeEventListener("spaced-repetition-updated", refreshDueCards);
};
}, [refreshDueCards]);
async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" });
@ -79,28 +47,20 @@ export function Navbar() {
{classSlug && (
<div className="pt-6">
<p className="mb-2 px-3 text-[10px] font-bold uppercase tracking-[0.18em] text-text-muted">Current class</p>
{STUDY_MODES.map((mode, index) => {
const isActive = pathname.includes(`/${mode.path}`);
return (
<Link
key={mode.key}
href={`/${classSlug}/${mode.path}`}
href={`/${classSlug}/flashcards`}
onClick={() => setOpen(false)}
className={`${index > 0 ? "mt-1 " : ""}flex min-h-11 items-center gap-3 rounded-xl px-3 text-sm font-semibold transition-colors ${isActive ? "bg-bg-callout text-primary" : "text-text-secondary hover:bg-bg-surface-alt hover:text-text-heading"}`}
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"}`}
>
<span aria-hidden>{mode.icon}</span> {mode.label}
{mode.key === "spaced-repetition" && hasReadyCards && (
<span
className="ml-auto grid h-5 w-5 place-items-center rounded-full bg-error-bg text-xs font-extrabold text-error"
title="Spaced repetition cards are ready to study"
>
<span aria-hidden>!</span>
<span className="sr-only">Cards ready to study</span>
</span>
)}
<span aria-hidden></span> Flashcards
</Link>
<Link
href={`/${classSlug}/quizzes`}
onClick={() => setOpen(false)}
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 aria-hidden></span> Quizzes
</Link>
);
})}
</div>
)}
</nav>
@ -119,11 +79,11 @@ export function Navbar() {
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}
</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">
<Mark />
<span className="editorial-title text-lg text-text-heading">Study Desk</span>

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect } from "react";
interface ShareMenuProps {
targetType: "DECK" | "QUIZ" | "GROUP";
@ -16,24 +16,6 @@ export function ShareMenu({ targetType, contentId, classSlug, compact = false }:
const [copied, setCopied] = useState(false);
const [isGroupShared, setIsGroupShared] = useState(false);
const [groupName, setGroupName] = useState<string | null>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handlePointerDown(event: PointerEvent) {
const target = event.target as Node;
if (buttonRef.current?.contains(target) || menuRef.current?.contains(target)) {
return;
}
setOpen(false);
}
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [open]);
useEffect(() => {
if (!open) return;
@ -81,7 +63,6 @@ export function ShareMenu({ targetType, contentId, classSlug, compact = false }:
return (
<div className="relative">
<button
ref={buttonRef}
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`}
title="Share"
@ -94,7 +75,7 @@ export function ShareMenu({ targetType, contentId, classSlug, compact = false }:
{open && (
<>
<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>
{loading ? (

View file

@ -1,34 +0,0 @@
export const ARCADE_GAMES = [
{
key: "connections",
name: "Connections",
path: "connections",
available: true,
estimatedMinutes: "510 min",
description: "Find four hidden relationships among sixteen terms.",
},
{
key: "crossword",
name: "Crossword",
path: "crossword",
available: true,
estimatedMinutes: "1045 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;

View file

@ -1,8 +1,6 @@
export const STUDY_MODES = [
{ key: "flashcards", label: "Flashcards", path: "flashcards", icon: "▤" },
{ key: "quizzes", label: "Quizzes", path: "quizzes", icon: "✓" },
{ key: "spaced-repetition", label: "Spaced Repetition", path: "spaced-repetition", icon: "↻" },
{ key: "arcade", label: "Arcade", path: "arcade", icon: "◆" },
{ key: "flashcards", label: "Flashcards", path: "flashcards" },
{ key: "quizzes", label: "Quizzes", path: "quizzes" },
// Future modes get added here — the layout, tab bar, and auth middleware need no changes.
] as const;

View file

@ -72,38 +72,8 @@ export type AuthSecurity = Prisma.AuthSecurityModel
*
*/
export type Setting = Prisma.SettingModel
/**
* Model StudyActivity
*
*/
export type StudyActivity = Prisma.StudyActivityModel
/**
* Model ArcadePack
*
*/
export type ArcadePack = Prisma.ArcadePackModel
/**
* Model ArcadeAttempt
*
*/
export type ArcadeAttempt = Prisma.ArcadeAttemptModel
/**
* Model MaterialGroup
*
*/
export type MaterialGroup = Prisma.MaterialGroupModel
/**
* Model SpacedRepetitionSet
*
*/
export type SpacedRepetitionSet = Prisma.SpacedRepetitionSetModel
/**
* Model SpacedRepetitionSetDeck
*
*/
export type SpacedRepetitionSetDeck = Prisma.SpacedRepetitionSetDeckModel
/**
* Model SpacedRepetitionCardState
*
*/
export type SpacedRepetitionCardState = Prisma.SpacedRepetitionCardStateModel

View file

@ -96,38 +96,8 @@ export type AuthSecurity = Prisma.AuthSecurityModel
*
*/
export type Setting = Prisma.SettingModel
/**
* Model StudyActivity
*
*/
export type StudyActivity = Prisma.StudyActivityModel
/**
* Model ArcadePack
*
*/
export type ArcadePack = Prisma.ArcadePackModel
/**
* Model ArcadeAttempt
*
*/
export type ArcadeAttempt = Prisma.ArcadeAttemptModel
/**
* Model MaterialGroup
*
*/
export type MaterialGroup = Prisma.MaterialGroupModel
/**
* Model SpacedRepetitionSet
*
*/
export type SpacedRepetitionSet = Prisma.SpacedRepetitionSetModel
/**
* Model SpacedRepetitionSetDeck
*
*/
export type SpacedRepetitionSetDeck = Prisma.SpacedRepetitionSetDeckModel
/**
* Model SpacedRepetitionCardState
*
*/
export type SpacedRepetitionCardState = Prisma.SpacedRepetitionCardStateModel

File diff suppressed because one or more lines are too long

View file

@ -395,13 +395,7 @@ export const ModelName = {
ShareLink: 'ShareLink',
AuthSecurity: 'AuthSecurity',
Setting: 'Setting',
StudyActivity: 'StudyActivity',
ArcadePack: 'ArcadePack',
ArcadeAttempt: 'ArcadeAttempt',
MaterialGroup: 'MaterialGroup',
SpacedRepetitionSet: 'SpacedRepetitionSet',
SpacedRepetitionSetDeck: 'SpacedRepetitionSetDeck',
SpacedRepetitionCardState: 'SpacedRepetitionCardState'
MaterialGroup: 'MaterialGroup'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@ -417,7 +411,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "class" | "deck" | "flashcard" | "quizSet" | "question" | "answerOption" | "studyProgress" | "quizAttempt" | "shareLink" | "authSecurity" | "setting" | "studyActivity" | "arcadePack" | "arcadeAttempt" | "materialGroup" | "spacedRepetitionSet" | "spacedRepetitionSetDeck" | "spacedRepetitionCardState"
modelProps: "class" | "deck" | "flashcard" | "quizSet" | "question" | "answerOption" | "studyProgress" | "quizAttempt" | "shareLink" | "authSecurity" | "setting" | "materialGroup"
txIsolationLevel: TransactionIsolationLevel
}
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: {
payload: Prisma.$MaterialGroupPayload<ExtArgs>
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: {
@ -1920,52 +1470,6 @@ export const SettingScalarFieldEnum = {
export type SettingScalarFieldEnum = (typeof SettingScalarFieldEnum)[keyof typeof SettingScalarFieldEnum]
export const StudyActivityScalarFieldEnum = {
id: 'id',
type: 'type',
occurredAt: 'occurredAt'
} as const
export type StudyActivityScalarFieldEnum = (typeof StudyActivityScalarFieldEnum)[keyof typeof StudyActivityScalarFieldEnum]
export const ArcadePackScalarFieldEnum = {
id: 'id',
classId: 'classId',
gameType: 'gameType',
name: 'name',
description: 'description',
schemaVersion: 'schemaVersion',
sourceJson: 'sourceJson',
normalizedJson: 'normalizedJson',
validationReportJson: 'validationReportJson',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ArcadePackScalarFieldEnum = (typeof ArcadePackScalarFieldEnum)[keyof typeof ArcadePackScalarFieldEnum]
export const ArcadeAttemptScalarFieldEnum = {
id: 'id',
arcadePackId: 'arcadePackId',
mode: 'mode',
score: 'score',
maxScore: 'maxScore',
accuracy: 'accuracy',
durationSeconds: 'durationSeconds',
mistakes: 'mistakes',
hintsUsed: 'hintsUsed',
settingsJson: 'settingsJson',
resultsJson: 'resultsJson',
seed: 'seed',
completedAt: 'completedAt'
} as const
export type ArcadeAttemptScalarFieldEnum = (typeof ArcadeAttemptScalarFieldEnum)[keyof typeof ArcadeAttemptScalarFieldEnum]
export const MaterialGroupScalarFieldEnum = {
id: 'id',
classId: 'classId',
@ -1978,52 +1482,6 @@ export const MaterialGroupScalarFieldEnum = {
export type MaterialGroupScalarFieldEnum = (typeof MaterialGroupScalarFieldEnum)[keyof typeof MaterialGroupScalarFieldEnum]
export const SpacedRepetitionSetScalarFieldEnum = {
id: 'id',
classId: 'classId',
name: 'name',
description: 'description',
newCardsPerDay: 'newCardsPerDay',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type SpacedRepetitionSetScalarFieldEnum = (typeof SpacedRepetitionSetScalarFieldEnum)[keyof typeof SpacedRepetitionSetScalarFieldEnum]
export const SpacedRepetitionSetDeckScalarFieldEnum = {
setId: 'setId',
deckId: 'deckId',
sortOrder: 'sortOrder',
addedAt: 'addedAt'
} as const
export type SpacedRepetitionSetDeckScalarFieldEnum = (typeof SpacedRepetitionSetDeckScalarFieldEnum)[keyof typeof SpacedRepetitionSetDeckScalarFieldEnum]
export const SpacedRepetitionCardStateScalarFieldEnum = {
id: 'id',
setId: 'setId',
flashcardId: 'flashcardId',
due: 'due',
stability: 'stability',
difficulty: 'difficulty',
elapsedDays: 'elapsedDays',
scheduledDays: 'scheduledDays',
learningSteps: 'learningSteps',
reps: 'reps',
lapses: 'lapses',
state: 'state',
firstReviewedAt: 'firstReviewedAt',
lastReview: 'lastReview',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type SpacedRepetitionCardStateScalarFieldEnum = (typeof SpacedRepetitionCardStateScalarFieldEnum)[keyof typeof SpacedRepetitionCardStateScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
@ -2201,13 +1659,7 @@ export type GlobalOmitConfig = {
shareLink?: Prisma.ShareLinkOmit
authSecurity?: Prisma.AuthSecurityOmit
setting?: Prisma.SettingOmit
studyActivity?: Prisma.StudyActivityOmit
arcadePack?: Prisma.ArcadePackOmit
arcadeAttempt?: Prisma.ArcadeAttemptOmit
materialGroup?: Prisma.MaterialGroupOmit
spacedRepetitionSet?: Prisma.SpacedRepetitionSetOmit
spacedRepetitionSetDeck?: Prisma.SpacedRepetitionSetDeckOmit
spacedRepetitionCardState?: Prisma.SpacedRepetitionCardStateOmit
}
/* Types for Logging */

View file

@ -62,13 +62,7 @@ export const ModelName = {
ShareLink: 'ShareLink',
AuthSecurity: 'AuthSecurity',
Setting: 'Setting',
StudyActivity: 'StudyActivity',
ArcadePack: 'ArcadePack',
ArcadeAttempt: 'ArcadeAttempt',
MaterialGroup: 'MaterialGroup',
SpacedRepetitionSet: 'SpacedRepetitionSet',
SpacedRepetitionSetDeck: 'SpacedRepetitionSetDeck',
SpacedRepetitionCardState: 'SpacedRepetitionCardState'
MaterialGroup: 'MaterialGroup'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@ -215,52 +209,6 @@ export const SettingScalarFieldEnum = {
export type SettingScalarFieldEnum = (typeof SettingScalarFieldEnum)[keyof typeof SettingScalarFieldEnum]
export const StudyActivityScalarFieldEnum = {
id: 'id',
type: 'type',
occurredAt: 'occurredAt'
} as const
export type StudyActivityScalarFieldEnum = (typeof StudyActivityScalarFieldEnum)[keyof typeof StudyActivityScalarFieldEnum]
export const ArcadePackScalarFieldEnum = {
id: 'id',
classId: 'classId',
gameType: 'gameType',
name: 'name',
description: 'description',
schemaVersion: 'schemaVersion',
sourceJson: 'sourceJson',
normalizedJson: 'normalizedJson',
validationReportJson: 'validationReportJson',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ArcadePackScalarFieldEnum = (typeof ArcadePackScalarFieldEnum)[keyof typeof ArcadePackScalarFieldEnum]
export const ArcadeAttemptScalarFieldEnum = {
id: 'id',
arcadePackId: 'arcadePackId',
mode: 'mode',
score: 'score',
maxScore: 'maxScore',
accuracy: 'accuracy',
durationSeconds: 'durationSeconds',
mistakes: 'mistakes',
hintsUsed: 'hintsUsed',
settingsJson: 'settingsJson',
resultsJson: 'resultsJson',
seed: 'seed',
completedAt: 'completedAt'
} as const
export type ArcadeAttemptScalarFieldEnum = (typeof ArcadeAttemptScalarFieldEnum)[keyof typeof ArcadeAttemptScalarFieldEnum]
export const MaterialGroupScalarFieldEnum = {
id: 'id',
classId: 'classId',
@ -273,52 +221,6 @@ export const MaterialGroupScalarFieldEnum = {
export type MaterialGroupScalarFieldEnum = (typeof MaterialGroupScalarFieldEnum)[keyof typeof MaterialGroupScalarFieldEnum]
export const SpacedRepetitionSetScalarFieldEnum = {
id: 'id',
classId: 'classId',
name: 'name',
description: 'description',
newCardsPerDay: 'newCardsPerDay',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type SpacedRepetitionSetScalarFieldEnum = (typeof SpacedRepetitionSetScalarFieldEnum)[keyof typeof SpacedRepetitionSetScalarFieldEnum]
export const SpacedRepetitionSetDeckScalarFieldEnum = {
setId: 'setId',
deckId: 'deckId',
sortOrder: 'sortOrder',
addedAt: 'addedAt'
} as const
export type SpacedRepetitionSetDeckScalarFieldEnum = (typeof SpacedRepetitionSetDeckScalarFieldEnum)[keyof typeof SpacedRepetitionSetDeckScalarFieldEnum]
export const SpacedRepetitionCardStateScalarFieldEnum = {
id: 'id',
setId: 'setId',
flashcardId: 'flashcardId',
due: 'due',
stability: 'stability',
difficulty: 'difficulty',
elapsedDays: 'elapsedDays',
scheduledDays: 'scheduledDays',
learningSteps: 'learningSteps',
reps: 'reps',
lapses: 'lapses',
state: 'state',
firstReviewedAt: 'firstReviewedAt',
lastReview: 'lastReview',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type SpacedRepetitionCardStateScalarFieldEnum = (typeof SpacedRepetitionCardStateScalarFieldEnum)[keyof typeof SpacedRepetitionCardStateScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'

View file

@ -19,11 +19,5 @@ export type * from './models/QuizAttempt'
export type * from './models/ShareLink'
export type * from './models/AuthSecurity'
export type * from './models/Setting'
export type * from './models/StudyActivity'
export type * from './models/ArcadePack'
export type * from './models/ArcadeAttempt'
export type * from './models/MaterialGroup'
export type * from './models/SpacedRepetitionSet'
export type * from './models/SpacedRepetitionSetDeck'
export type * from './models/SpacedRepetitionCardState'
export type * from './commonInputTypes'

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -219,8 +219,6 @@ export type ClassWhereInput = {
decks?: Prisma.DeckListRelationFilter
quizSets?: Prisma.QuizSetListRelationFilter
materialGroups?: Prisma.MaterialGroupListRelationFilter
spacedRepetitionSets?: Prisma.SpacedRepetitionSetListRelationFilter
arcadePacks?: Prisma.ArcadePackListRelationFilter
}
export type ClassOrderByWithRelationInput = {
@ -232,8 +230,6 @@ export type ClassOrderByWithRelationInput = {
decks?: Prisma.DeckOrderByRelationAggregateInput
quizSets?: Prisma.QuizSetOrderByRelationAggregateInput
materialGroups?: Prisma.MaterialGroupOrderByRelationAggregateInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetOrderByRelationAggregateInput
arcadePacks?: Prisma.ArcadePackOrderByRelationAggregateInput
}
export type ClassWhereUniqueInput = Prisma.AtLeast<{
@ -248,8 +244,6 @@ export type ClassWhereUniqueInput = Prisma.AtLeast<{
decks?: Prisma.DeckListRelationFilter
quizSets?: Prisma.QuizSetListRelationFilter
materialGroups?: Prisma.MaterialGroupListRelationFilter
spacedRepetitionSets?: Prisma.SpacedRepetitionSetListRelationFilter
arcadePacks?: Prisma.ArcadePackListRelationFilter
}, "id" | "slug">
export type ClassOrderByWithAggregationInput = {
@ -285,8 +279,6 @@ export type ClassCreateInput = {
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateInput = {
@ -298,8 +290,6 @@ export type ClassUncheckedCreateInput = {
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
}
export type ClassUpdateInput = {
@ -311,8 +301,6 @@ export type ClassUpdateInput = {
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateInput = {
@ -324,8 +312,6 @@ export type ClassUncheckedUpdateInput = {
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateManyInput = {
@ -433,20 +419,6 @@ export type ClassUpdateOneRequiredWithoutQuizSetsNestedInput = {
update?: Prisma.XOR<Prisma.XOR<Prisma.ClassUpdateToOneWithWhereWithoutQuizSetsInput, Prisma.ClassUpdateWithoutQuizSetsInput>, Prisma.ClassUncheckedUpdateWithoutQuizSetsInput>
}
export type ClassCreateNestedOneWithoutArcadePacksInput = {
create?: Prisma.XOR<Prisma.ClassCreateWithoutArcadePacksInput, Prisma.ClassUncheckedCreateWithoutArcadePacksInput>
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutArcadePacksInput
connect?: Prisma.ClassWhereUniqueInput
}
export type ClassUpdateOneRequiredWithoutArcadePacksNestedInput = {
create?: Prisma.XOR<Prisma.ClassCreateWithoutArcadePacksInput, Prisma.ClassUncheckedCreateWithoutArcadePacksInput>
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutArcadePacksInput
upsert?: Prisma.ClassUpsertWithoutArcadePacksInput
connect?: Prisma.ClassWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.ClassUpdateToOneWithWhereWithoutArcadePacksInput, Prisma.ClassUpdateWithoutArcadePacksInput>, Prisma.ClassUncheckedUpdateWithoutArcadePacksInput>
}
export type ClassCreateNestedOneWithoutMaterialGroupsInput = {
create?: Prisma.XOR<Prisma.ClassCreateWithoutMaterialGroupsInput, Prisma.ClassUncheckedCreateWithoutMaterialGroupsInput>
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutMaterialGroupsInput
@ -461,20 +433,6 @@ export type ClassUpdateOneRequiredWithoutMaterialGroupsNestedInput = {
update?: Prisma.XOR<Prisma.XOR<Prisma.ClassUpdateToOneWithWhereWithoutMaterialGroupsInput, Prisma.ClassUpdateWithoutMaterialGroupsInput>, Prisma.ClassUncheckedUpdateWithoutMaterialGroupsInput>
}
export type ClassCreateNestedOneWithoutSpacedRepetitionSetsInput = {
create?: Prisma.XOR<Prisma.ClassCreateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedCreateWithoutSpacedRepetitionSetsInput>
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutSpacedRepetitionSetsInput
connect?: Prisma.ClassWhereUniqueInput
}
export type ClassUpdateOneRequiredWithoutSpacedRepetitionSetsNestedInput = {
create?: Prisma.XOR<Prisma.ClassCreateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedCreateWithoutSpacedRepetitionSetsInput>
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutSpacedRepetitionSetsInput
upsert?: Prisma.ClassUpsertWithoutSpacedRepetitionSetsInput
connect?: Prisma.ClassWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.ClassUpdateToOneWithWhereWithoutSpacedRepetitionSetsInput, Prisma.ClassUpdateWithoutSpacedRepetitionSetsInput>, Prisma.ClassUncheckedUpdateWithoutSpacedRepetitionSetsInput>
}
export type ClassCreateWithoutDecksInput = {
id?: string
slug: string
@ -483,8 +441,6 @@ export type ClassCreateWithoutDecksInput = {
createdAt?: Date | string
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutDecksInput = {
@ -495,8 +451,6 @@ export type ClassUncheckedCreateWithoutDecksInput = {
createdAt?: Date | string
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutDecksInput = {
@ -523,8 +477,6 @@ export type ClassUpdateWithoutDecksInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutDecksInput = {
@ -535,8 +487,6 @@ export type ClassUncheckedUpdateWithoutDecksInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateWithoutQuizSetsInput = {
@ -547,8 +497,6 @@ export type ClassCreateWithoutQuizSetsInput = {
createdAt?: Date | string
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutQuizSetsInput = {
@ -559,8 +507,6 @@ export type ClassUncheckedCreateWithoutQuizSetsInput = {
createdAt?: Date | string
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutQuizSetsInput = {
@ -587,8 +533,6 @@ export type ClassUpdateWithoutQuizSetsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutQuizSetsInput = {
@ -599,72 +543,6 @@ export type ClassUncheckedUpdateWithoutQuizSetsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateWithoutArcadePacksInput = {
id?: string
slug: string
name: string
sortOrder?: number
createdAt?: Date | string
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutArcadePacksInput = {
id?: string
slug: string
name: string
sortOrder?: number
createdAt?: Date | string
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutArcadePacksInput = {
where: Prisma.ClassWhereUniqueInput
create: Prisma.XOR<Prisma.ClassCreateWithoutArcadePacksInput, Prisma.ClassUncheckedCreateWithoutArcadePacksInput>
}
export type ClassUpsertWithoutArcadePacksInput = {
update: Prisma.XOR<Prisma.ClassUpdateWithoutArcadePacksInput, Prisma.ClassUncheckedUpdateWithoutArcadePacksInput>
create: Prisma.XOR<Prisma.ClassCreateWithoutArcadePacksInput, Prisma.ClassUncheckedCreateWithoutArcadePacksInput>
where?: Prisma.ClassWhereInput
}
export type ClassUpdateToOneWithWhereWithoutArcadePacksInput = {
where?: Prisma.ClassWhereInput
data: Prisma.XOR<Prisma.ClassUpdateWithoutArcadePacksInput, Prisma.ClassUncheckedUpdateWithoutArcadePacksInput>
}
export type ClassUpdateWithoutArcadePacksInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
slug?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutArcadePacksInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
slug?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateWithoutMaterialGroupsInput = {
@ -675,8 +553,6 @@ export type ClassCreateWithoutMaterialGroupsInput = {
createdAt?: Date | string
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutMaterialGroupsInput = {
@ -687,8 +563,6 @@ export type ClassUncheckedCreateWithoutMaterialGroupsInput = {
createdAt?: Date | string
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutMaterialGroupsInput = {
@ -715,8 +589,6 @@ export type ClassUpdateWithoutMaterialGroupsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutMaterialGroupsInput = {
@ -727,72 +599,6 @@ export type ClassUncheckedUpdateWithoutMaterialGroupsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateWithoutSpacedRepetitionSetsInput = {
id?: string
slug: string
name: string
sortOrder?: number
createdAt?: Date | string
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutSpacedRepetitionSetsInput = {
id?: string
slug: string
name: string
sortOrder?: number
createdAt?: Date | string
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutSpacedRepetitionSetsInput = {
where: Prisma.ClassWhereUniqueInput
create: Prisma.XOR<Prisma.ClassCreateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedCreateWithoutSpacedRepetitionSetsInput>
}
export type ClassUpsertWithoutSpacedRepetitionSetsInput = {
update: Prisma.XOR<Prisma.ClassUpdateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedUpdateWithoutSpacedRepetitionSetsInput>
create: Prisma.XOR<Prisma.ClassCreateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedCreateWithoutSpacedRepetitionSetsInput>
where?: Prisma.ClassWhereInput
}
export type ClassUpdateToOneWithWhereWithoutSpacedRepetitionSetsInput = {
where?: Prisma.ClassWhereInput
data: Prisma.XOR<Prisma.ClassUpdateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedUpdateWithoutSpacedRepetitionSetsInput>
}
export type ClassUpdateWithoutSpacedRepetitionSetsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
slug?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutSpacedRepetitionSetsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
slug?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
}
@ -804,16 +610,12 @@ export type ClassCountOutputType = {
decks: number
quizSets: number
materialGroups: number
spacedRepetitionSets: number
arcadePacks: number
}
export type ClassCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
decks?: boolean | ClassCountOutputTypeCountDecksArgs
quizSets?: boolean | ClassCountOutputTypeCountQuizSetsArgs
materialGroups?: boolean | ClassCountOutputTypeCountMaterialGroupsArgs
spacedRepetitionSets?: boolean | ClassCountOutputTypeCountSpacedRepetitionSetsArgs
arcadePacks?: boolean | ClassCountOutputTypeCountArcadePacksArgs
}
/**
@ -847,20 +649,6 @@ export type ClassCountOutputTypeCountMaterialGroupsArgs<ExtArgs extends runtime.
where?: Prisma.MaterialGroupWhereInput
}
/**
* ClassCountOutputType without action
*/
export type ClassCountOutputTypeCountSpacedRepetitionSetsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.SpacedRepetitionSetWhereInput
}
/**
* ClassCountOutputType without action
*/
export type ClassCountOutputTypeCountArcadePacksArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.ArcadePackWhereInput
}
export type ClassSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
@ -871,8 +659,6 @@ export type ClassSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs =
decks?: boolean | Prisma.Class$decksArgs<ExtArgs>
quizSets?: boolean | Prisma.Class$quizSetsArgs<ExtArgs>
materialGroups?: boolean | Prisma.Class$materialGroupsArgs<ExtArgs>
spacedRepetitionSets?: boolean | Prisma.Class$spacedRepetitionSetsArgs<ExtArgs>
arcadePacks?: boolean | Prisma.Class$arcadePacksArgs<ExtArgs>
_count?: boolean | Prisma.ClassCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["class"]>
@ -905,8 +691,6 @@ export type ClassInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
decks?: boolean | Prisma.Class$decksArgs<ExtArgs>
quizSets?: boolean | Prisma.Class$quizSetsArgs<ExtArgs>
materialGroups?: boolean | Prisma.Class$materialGroupsArgs<ExtArgs>
spacedRepetitionSets?: boolean | Prisma.Class$spacedRepetitionSetsArgs<ExtArgs>
arcadePacks?: boolean | Prisma.Class$arcadePacksArgs<ExtArgs>
_count?: boolean | Prisma.ClassCountOutputTypeDefaultArgs<ExtArgs>
}
export type ClassIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
@ -918,8 +702,6 @@ export type $ClassPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs
decks: Prisma.$DeckPayload<ExtArgs>[]
quizSets: Prisma.$QuizSetPayload<ExtArgs>[]
materialGroups: Prisma.$MaterialGroupPayload<ExtArgs>[]
spacedRepetitionSets: Prisma.$SpacedRepetitionSetPayload<ExtArgs>[]
arcadePacks: Prisma.$ArcadePackPayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@ -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>
quizSets<T extends Prisma.Class$quizSetsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$quizSetsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$QuizSetPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
materialGroups<T extends Prisma.Class$materialGroupsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$materialGroupsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$MaterialGroupPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
spacedRepetitionSets<T extends Prisma.Class$spacedRepetitionSetsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$spacedRepetitionSetsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$SpacedRepetitionSetPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
arcadePacks<T extends Prisma.Class$arcadePacksArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$arcadePacksArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$ArcadePackPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@ -1822,54 +1602,6 @@ export type Class$materialGroupsArgs<ExtArgs extends runtime.Types.Extensions.In
distinct?: Prisma.MaterialGroupScalarFieldEnum | Prisma.MaterialGroupScalarFieldEnum[]
}
/**
* Class.spacedRepetitionSets
*/
export type Class$spacedRepetitionSetsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the SpacedRepetitionSet
*/
select?: Prisma.SpacedRepetitionSetSelect<ExtArgs> | null
/**
* Omit specific fields from the SpacedRepetitionSet
*/
omit?: Prisma.SpacedRepetitionSetOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.SpacedRepetitionSetInclude<ExtArgs> | null
where?: Prisma.SpacedRepetitionSetWhereInput
orderBy?: Prisma.SpacedRepetitionSetOrderByWithRelationInput | Prisma.SpacedRepetitionSetOrderByWithRelationInput[]
cursor?: Prisma.SpacedRepetitionSetWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.SpacedRepetitionSetScalarFieldEnum | Prisma.SpacedRepetitionSetScalarFieldEnum[]
}
/**
* Class.arcadePacks
*/
export type Class$arcadePacksArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the ArcadePack
*/
select?: Prisma.ArcadePackSelect<ExtArgs> | null
/**
* Omit specific fields from the ArcadePack
*/
omit?: Prisma.ArcadePackOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.ArcadePackInclude<ExtArgs> | null
where?: Prisma.ArcadePackWhereInput
orderBy?: Prisma.ArcadePackOrderByWithRelationInput | Prisma.ArcadePackOrderByWithRelationInput[]
cursor?: Prisma.ArcadePackWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.ArcadePackScalarFieldEnum | Prisma.ArcadePackScalarFieldEnum[]
}
/**
* Class without action
*/

View file

@ -237,7 +237,6 @@ export type DeckWhereInput = {
cards?: Prisma.FlashcardListRelationFilter
shareLink?: Prisma.XOR<Prisma.ShareLinkNullableScalarRelationFilter, Prisma.ShareLinkWhereInput> | null
progress?: Prisma.StudyProgressListRelationFilter
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckListRelationFilter
}
export type DeckOrderByWithRelationInput = {
@ -253,7 +252,6 @@ export type DeckOrderByWithRelationInput = {
cards?: Prisma.FlashcardOrderByRelationAggregateInput
shareLink?: Prisma.ShareLinkOrderByWithRelationInput
progress?: Prisma.StudyProgressOrderByRelationAggregateInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckOrderByRelationAggregateInput
}
export type DeckWhereUniqueInput = Prisma.AtLeast<{
@ -272,7 +270,6 @@ export type DeckWhereUniqueInput = Prisma.AtLeast<{
cards?: Prisma.FlashcardListRelationFilter
shareLink?: Prisma.XOR<Prisma.ShareLinkNullableScalarRelationFilter, Prisma.ShareLinkWhereInput> | null
progress?: Prisma.StudyProgressListRelationFilter
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckListRelationFilter
}, "id">
export type DeckOrderByWithAggregationInput = {
@ -314,7 +311,6 @@ export type DeckCreateInput = {
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
}
export type DeckUncheckedCreateInput = {
@ -328,7 +324,6 @@ export type DeckUncheckedCreateInput = {
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
}
export type DeckUpdateInput = {
@ -342,7 +337,6 @@ export type DeckUpdateInput = {
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateInput = {
@ -356,7 +350,6 @@ export type DeckUncheckedUpdateInput = {
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
}
export type DeckCreateManyInput = {
@ -579,20 +572,6 @@ export type DeckUncheckedUpdateManyWithoutGroupNestedInput = {
deleteMany?: Prisma.DeckScalarWhereInput | Prisma.DeckScalarWhereInput[]
}
export type DeckCreateNestedOneWithoutSpacedRepetitionMembershipsInput = {
create?: Prisma.XOR<Prisma.DeckCreateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedCreateWithoutSpacedRepetitionMembershipsInput>
connectOrCreate?: Prisma.DeckCreateOrConnectWithoutSpacedRepetitionMembershipsInput
connect?: Prisma.DeckWhereUniqueInput
}
export type DeckUpdateOneRequiredWithoutSpacedRepetitionMembershipsNestedInput = {
create?: Prisma.XOR<Prisma.DeckCreateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedCreateWithoutSpacedRepetitionMembershipsInput>
connectOrCreate?: Prisma.DeckCreateOrConnectWithoutSpacedRepetitionMembershipsInput
upsert?: Prisma.DeckUpsertWithoutSpacedRepetitionMembershipsInput
connect?: Prisma.DeckWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.DeckUpdateToOneWithWhereWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUpdateWithoutSpacedRepetitionMembershipsInput>, Prisma.DeckUncheckedUpdateWithoutSpacedRepetitionMembershipsInput>
}
export type DeckCreateWithoutClassInput = {
id?: string
name: string
@ -603,7 +582,6 @@ export type DeckCreateWithoutClassInput = {
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
}
export type DeckUncheckedCreateWithoutClassInput = {
@ -616,7 +594,6 @@ export type DeckUncheckedCreateWithoutClassInput = {
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
}
export type DeckCreateOrConnectWithoutClassInput = {
@ -667,7 +644,6 @@ export type DeckCreateWithoutCardsInput = {
group?: Prisma.MaterialGroupCreateNestedOneWithoutDecksInput
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
}
export type DeckUncheckedCreateWithoutCardsInput = {
@ -680,7 +656,6 @@ export type DeckUncheckedCreateWithoutCardsInput = {
groupId?: string | null
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
}
export type DeckCreateOrConnectWithoutCardsInput = {
@ -709,7 +684,6 @@ export type DeckUpdateWithoutCardsInput = {
group?: Prisma.MaterialGroupUpdateOneWithoutDecksNestedInput
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateWithoutCardsInput = {
@ -722,7 +696,6 @@ export type DeckUncheckedUpdateWithoutCardsInput = {
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
}
export type DeckCreateWithoutProgressInput = {
@ -735,7 +708,6 @@ export type DeckCreateWithoutProgressInput = {
group?: Prisma.MaterialGroupCreateNestedOneWithoutDecksInput
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
}
export type DeckUncheckedCreateWithoutProgressInput = {
@ -748,7 +720,6 @@ export type DeckUncheckedCreateWithoutProgressInput = {
groupId?: string | null
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
}
export type DeckCreateOrConnectWithoutProgressInput = {
@ -777,7 +748,6 @@ export type DeckUpdateWithoutProgressInput = {
group?: Prisma.MaterialGroupUpdateOneWithoutDecksNestedInput
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateWithoutProgressInput = {
@ -790,7 +760,6 @@ export type DeckUncheckedUpdateWithoutProgressInput = {
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
}
export type DeckCreateWithoutShareLinkInput = {
@ -803,7 +772,6 @@ export type DeckCreateWithoutShareLinkInput = {
group?: Prisma.MaterialGroupCreateNestedOneWithoutDecksInput
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
}
export type DeckUncheckedCreateWithoutShareLinkInput = {
@ -816,7 +784,6 @@ export type DeckUncheckedCreateWithoutShareLinkInput = {
groupId?: string | null
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
}
export type DeckCreateOrConnectWithoutShareLinkInput = {
@ -845,7 +812,6 @@ export type DeckUpdateWithoutShareLinkInput = {
group?: Prisma.MaterialGroupUpdateOneWithoutDecksNestedInput
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateWithoutShareLinkInput = {
@ -858,7 +824,6 @@ export type DeckUncheckedUpdateWithoutShareLinkInput = {
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
}
export type DeckCreateWithoutGroupInput = {
@ -871,7 +836,6 @@ export type DeckCreateWithoutGroupInput = {
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
}
export type DeckUncheckedCreateWithoutGroupInput = {
@ -884,7 +848,6 @@ export type DeckUncheckedCreateWithoutGroupInput = {
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
}
export type DeckCreateOrConnectWithoutGroupInput = {
@ -912,74 +875,6 @@ export type DeckUpdateManyWithWhereWithoutGroupInput = {
data: Prisma.XOR<Prisma.DeckUpdateManyMutationInput, Prisma.DeckUncheckedUpdateManyWithoutGroupInput>
}
export type DeckCreateWithoutSpacedRepetitionMembershipsInput = {
id?: string
name: string
description?: string | null
sortOrder?: number
createdAt?: Date | string
class: Prisma.ClassCreateNestedOneWithoutDecksInput
group?: Prisma.MaterialGroupCreateNestedOneWithoutDecksInput
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
}
export type DeckUncheckedCreateWithoutSpacedRepetitionMembershipsInput = {
id?: string
classId: string
name: string
description?: string | null
sortOrder?: number
createdAt?: Date | string
groupId?: string | null
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
}
export type DeckCreateOrConnectWithoutSpacedRepetitionMembershipsInput = {
where: Prisma.DeckWhereUniqueInput
create: Prisma.XOR<Prisma.DeckCreateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedCreateWithoutSpacedRepetitionMembershipsInput>
}
export type DeckUpsertWithoutSpacedRepetitionMembershipsInput = {
update: Prisma.XOR<Prisma.DeckUpdateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedUpdateWithoutSpacedRepetitionMembershipsInput>
create: Prisma.XOR<Prisma.DeckCreateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedCreateWithoutSpacedRepetitionMembershipsInput>
where?: Prisma.DeckWhereInput
}
export type DeckUpdateToOneWithWhereWithoutSpacedRepetitionMembershipsInput = {
where?: Prisma.DeckWhereInput
data: Prisma.XOR<Prisma.DeckUpdateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedUpdateWithoutSpacedRepetitionMembershipsInput>
}
export type DeckUpdateWithoutSpacedRepetitionMembershipsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
class?: Prisma.ClassUpdateOneRequiredWithoutDecksNestedInput
group?: Prisma.MaterialGroupUpdateOneWithoutDecksNestedInput
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateWithoutSpacedRepetitionMembershipsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
classId?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
}
export type DeckCreateManyClassInput = {
id?: string
name: string
@ -999,7 +894,6 @@ export type DeckUpdateWithoutClassInput = {
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateWithoutClassInput = {
@ -1012,7 +906,6 @@ export type DeckUncheckedUpdateWithoutClassInput = {
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateManyWithoutClassInput = {
@ -1043,7 +936,6 @@ export type DeckUpdateWithoutGroupInput = {
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateWithoutGroupInput = {
@ -1056,7 +948,6 @@ export type DeckUncheckedUpdateWithoutGroupInput = {
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateManyWithoutGroupInput = {
@ -1076,13 +967,11 @@ export type DeckUncheckedUpdateManyWithoutGroupInput = {
export type DeckCountOutputType = {
cards: number
progress: number
spacedRepetitionMemberships: number
}
export type DeckCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
cards?: boolean | DeckCountOutputTypeCountCardsArgs
progress?: boolean | DeckCountOutputTypeCountProgressArgs
spacedRepetitionMemberships?: boolean | DeckCountOutputTypeCountSpacedRepetitionMembershipsArgs
}
/**
@ -1109,13 +998,6 @@ export type DeckCountOutputTypeCountProgressArgs<ExtArgs extends runtime.Types.E
where?: Prisma.StudyProgressWhereInput
}
/**
* DeckCountOutputType without action
*/
export type DeckCountOutputTypeCountSpacedRepetitionMembershipsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.SpacedRepetitionSetDeckWhereInput
}
export type DeckSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
@ -1130,7 +1012,6 @@ export type DeckSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
cards?: boolean | Prisma.Deck$cardsArgs<ExtArgs>
shareLink?: boolean | Prisma.Deck$shareLinkArgs<ExtArgs>
progress?: boolean | Prisma.Deck$progressArgs<ExtArgs>
spacedRepetitionMemberships?: boolean | Prisma.Deck$spacedRepetitionMembershipsArgs<ExtArgs>
_count?: boolean | Prisma.DeckCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["deck"]>
@ -1175,7 +1056,6 @@ export type DeckInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
cards?: boolean | Prisma.Deck$cardsArgs<ExtArgs>
shareLink?: boolean | Prisma.Deck$shareLinkArgs<ExtArgs>
progress?: boolean | Prisma.Deck$progressArgs<ExtArgs>
spacedRepetitionMemberships?: boolean | Prisma.Deck$spacedRepetitionMembershipsArgs<ExtArgs>
_count?: boolean | Prisma.DeckCountOutputTypeDefaultArgs<ExtArgs>
}
export type DeckIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@ -1195,7 +1075,6 @@ export type $DeckPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
cards: Prisma.$FlashcardPayload<ExtArgs>[]
shareLink: Prisma.$ShareLinkPayload<ExtArgs> | null
progress: Prisma.$StudyProgressPayload<ExtArgs>[]
spacedRepetitionMemberships: Prisma.$SpacedRepetitionSetDeckPayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@ -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>
shareLink<T extends Prisma.Deck$shareLinkArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Deck$shareLinkArgs<ExtArgs>>): Prisma.Prisma__ShareLinkClient<runtime.Types.Result.GetResult<Prisma.$ShareLinkPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
progress<T extends Prisma.Deck$progressArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Deck$progressArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$StudyProgressPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
spacedRepetitionMemberships<T extends Prisma.Deck$spacedRepetitionMembershipsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Deck$spacedRepetitionMembershipsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$SpacedRepetitionSetDeckPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@ -2125,30 +2003,6 @@ export type Deck$progressArgs<ExtArgs extends runtime.Types.Extensions.InternalA
distinct?: Prisma.StudyProgressScalarFieldEnum | Prisma.StudyProgressScalarFieldEnum[]
}
/**
* Deck.spacedRepetitionMemberships
*/
export type Deck$spacedRepetitionMembershipsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the SpacedRepetitionSetDeck
*/
select?: Prisma.SpacedRepetitionSetDeckSelect<ExtArgs> | null
/**
* Omit specific fields from the SpacedRepetitionSetDeck
*/
omit?: Prisma.SpacedRepetitionSetDeckOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.SpacedRepetitionSetDeckInclude<ExtArgs> | null
where?: Prisma.SpacedRepetitionSetDeckWhereInput
orderBy?: Prisma.SpacedRepetitionSetDeckOrderByWithRelationInput | Prisma.SpacedRepetitionSetDeckOrderByWithRelationInput[]
cursor?: Prisma.SpacedRepetitionSetDeckWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.SpacedRepetitionSetDeckScalarFieldEnum | Prisma.SpacedRepetitionSetDeckScalarFieldEnum[]
}
/**
* Deck without action
*/

View file

@ -217,7 +217,6 @@ export type FlashcardWhereInput = {
back?: Prisma.StringFilter<"Flashcard"> | string
sortOrder?: Prisma.IntFilter<"Flashcard"> | number
deck?: Prisma.XOR<Prisma.DeckScalarRelationFilter, Prisma.DeckWhereInput>
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateListRelationFilter
}
export type FlashcardOrderByWithRelationInput = {
@ -227,7 +226,6 @@ export type FlashcardOrderByWithRelationInput = {
back?: Prisma.SortOrder
sortOrder?: Prisma.SortOrder
deck?: Prisma.DeckOrderByWithRelationInput
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateOrderByRelationAggregateInput
}
export type FlashcardWhereUniqueInput = Prisma.AtLeast<{
@ -240,7 +238,6 @@ export type FlashcardWhereUniqueInput = Prisma.AtLeast<{
back?: Prisma.StringFilter<"Flashcard"> | string
sortOrder?: Prisma.IntFilter<"Flashcard"> | number
deck?: Prisma.XOR<Prisma.DeckScalarRelationFilter, Prisma.DeckWhereInput>
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateListRelationFilter
}, "id">
export type FlashcardOrderByWithAggregationInput = {
@ -273,7 +270,6 @@ export type FlashcardCreateInput = {
back: string
sortOrder?: number
deck: Prisma.DeckCreateNestedOneWithoutCardsInput
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateCreateNestedManyWithoutFlashcardInput
}
export type FlashcardUncheckedCreateInput = {
@ -282,7 +278,6 @@ export type FlashcardUncheckedCreateInput = {
front: string
back: string
sortOrder?: number
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUncheckedCreateNestedManyWithoutFlashcardInput
}
export type FlashcardUpdateInput = {
@ -291,7 +286,6 @@ export type FlashcardUpdateInput = {
back?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
deck?: Prisma.DeckUpdateOneRequiredWithoutCardsNestedInput
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUpdateManyWithoutFlashcardNestedInput
}
export type FlashcardUncheckedUpdateInput = {
@ -300,7 +294,6 @@ export type FlashcardUncheckedUpdateInput = {
front?: Prisma.StringFieldUpdateOperationsInput | string
back?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUncheckedUpdateManyWithoutFlashcardNestedInput
}
export type FlashcardCreateManyInput = {
@ -368,11 +361,6 @@ export type FlashcardSumOrderByAggregateInput = {
sortOrder?: Prisma.SortOrder
}
export type FlashcardScalarRelationFilter = {
is?: Prisma.FlashcardWhereInput
isNot?: Prisma.FlashcardWhereInput
}
export type FlashcardCreateNestedManyWithoutDeckInput = {
create?: Prisma.XOR<Prisma.FlashcardCreateWithoutDeckInput, Prisma.FlashcardUncheckedCreateWithoutDeckInput> | Prisma.FlashcardCreateWithoutDeckInput[] | Prisma.FlashcardUncheckedCreateWithoutDeckInput[]
connectOrCreate?: Prisma.FlashcardCreateOrConnectWithoutDeckInput | Prisma.FlashcardCreateOrConnectWithoutDeckInput[]
@ -415,26 +403,11 @@ export type FlashcardUncheckedUpdateManyWithoutDeckNestedInput = {
deleteMany?: Prisma.FlashcardScalarWhereInput | Prisma.FlashcardScalarWhereInput[]
}
export type FlashcardCreateNestedOneWithoutSpacedRepetitionStatesInput = {
create?: Prisma.XOR<Prisma.FlashcardCreateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedCreateWithoutSpacedRepetitionStatesInput>
connectOrCreate?: Prisma.FlashcardCreateOrConnectWithoutSpacedRepetitionStatesInput
connect?: Prisma.FlashcardWhereUniqueInput
}
export type FlashcardUpdateOneRequiredWithoutSpacedRepetitionStatesNestedInput = {
create?: Prisma.XOR<Prisma.FlashcardCreateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedCreateWithoutSpacedRepetitionStatesInput>
connectOrCreate?: Prisma.FlashcardCreateOrConnectWithoutSpacedRepetitionStatesInput
upsert?: Prisma.FlashcardUpsertWithoutSpacedRepetitionStatesInput
connect?: Prisma.FlashcardWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.FlashcardUpdateToOneWithWhereWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUpdateWithoutSpacedRepetitionStatesInput>, Prisma.FlashcardUncheckedUpdateWithoutSpacedRepetitionStatesInput>
}
export type FlashcardCreateWithoutDeckInput = {
id?: string
front: string
back: string
sortOrder?: number
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateCreateNestedManyWithoutFlashcardInput
}
export type FlashcardUncheckedCreateWithoutDeckInput = {
@ -442,7 +415,6 @@ export type FlashcardUncheckedCreateWithoutDeckInput = {
front: string
back: string
sortOrder?: number
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUncheckedCreateNestedManyWithoutFlashcardInput
}
export type FlashcardCreateOrConnectWithoutDeckInput = {
@ -481,54 +453,6 @@ export type FlashcardScalarWhereInput = {
sortOrder?: Prisma.IntFilter<"Flashcard"> | number
}
export type FlashcardCreateWithoutSpacedRepetitionStatesInput = {
id?: string
front: string
back: string
sortOrder?: number
deck: Prisma.DeckCreateNestedOneWithoutCardsInput
}
export type FlashcardUncheckedCreateWithoutSpacedRepetitionStatesInput = {
id?: string
deckId: string
front: string
back: string
sortOrder?: number
}
export type FlashcardCreateOrConnectWithoutSpacedRepetitionStatesInput = {
where: Prisma.FlashcardWhereUniqueInput
create: Prisma.XOR<Prisma.FlashcardCreateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedCreateWithoutSpacedRepetitionStatesInput>
}
export type FlashcardUpsertWithoutSpacedRepetitionStatesInput = {
update: Prisma.XOR<Prisma.FlashcardUpdateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedUpdateWithoutSpacedRepetitionStatesInput>
create: Prisma.XOR<Prisma.FlashcardCreateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedCreateWithoutSpacedRepetitionStatesInput>
where?: Prisma.FlashcardWhereInput
}
export type FlashcardUpdateToOneWithWhereWithoutSpacedRepetitionStatesInput = {
where?: Prisma.FlashcardWhereInput
data: Prisma.XOR<Prisma.FlashcardUpdateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedUpdateWithoutSpacedRepetitionStatesInput>
}
export type FlashcardUpdateWithoutSpacedRepetitionStatesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
front?: Prisma.StringFieldUpdateOperationsInput | string
back?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
deck?: Prisma.DeckUpdateOneRequiredWithoutCardsNestedInput
}
export type FlashcardUncheckedUpdateWithoutSpacedRepetitionStatesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
deckId?: Prisma.StringFieldUpdateOperationsInput | string
front?: Prisma.StringFieldUpdateOperationsInput | string
back?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
}
export type FlashcardCreateManyDeckInput = {
id?: string
front: string
@ -541,7 +465,6 @@ export type FlashcardUpdateWithoutDeckInput = {
front?: Prisma.StringFieldUpdateOperationsInput | string
back?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUpdateManyWithoutFlashcardNestedInput
}
export type FlashcardUncheckedUpdateWithoutDeckInput = {
@ -549,7 +472,6 @@ export type FlashcardUncheckedUpdateWithoutDeckInput = {
front?: Prisma.StringFieldUpdateOperationsInput | string
back?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUncheckedUpdateManyWithoutFlashcardNestedInput
}
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<{
id?: boolean
@ -597,8 +490,6 @@ export type FlashcardSelect<ExtArgs extends runtime.Types.Extensions.InternalArg
back?: boolean
sortOrder?: boolean
deck?: boolean | Prisma.DeckDefaultArgs<ExtArgs>
spacedRepetitionStates?: boolean | Prisma.Flashcard$spacedRepetitionStatesArgs<ExtArgs>
_count?: boolean | Prisma.FlashcardCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["flashcard"]>
export type FlashcardSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
@ -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 FlashcardInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
deck?: boolean | Prisma.DeckDefaultArgs<ExtArgs>
spacedRepetitionStates?: boolean | Prisma.Flashcard$spacedRepetitionStatesArgs<ExtArgs>
_count?: boolean | Prisma.FlashcardCountOutputTypeDefaultArgs<ExtArgs>
}
export type FlashcardIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
deck?: boolean | Prisma.DeckDefaultArgs<ExtArgs>
@ -644,7 +533,6 @@ export type $FlashcardPayload<ExtArgs extends runtime.Types.Extensions.InternalA
name: "Flashcard"
objects: {
deck: Prisma.$DeckPayload<ExtArgs>
spacedRepetitionStates: Prisma.$SpacedRepetitionCardStatePayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@ -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> {
readonly [Symbol.toStringTag]: "PrismaPromise"
deck<T extends Prisma.DeckDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.DeckDefaultArgs<ExtArgs>>): Prisma.Prisma__DeckClient<runtime.Types.Result.GetResult<Prisma.$DeckPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
spacedRepetitionStates<T extends Prisma.Flashcard$spacedRepetitionStatesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Flashcard$spacedRepetitionStatesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$SpacedRepetitionCardStatePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@ -1480,30 +1367,6 @@ export type FlashcardDeleteManyArgs<ExtArgs extends runtime.Types.Extensions.Int
limit?: number
}
/**
* Flashcard.spacedRepetitionStates
*/
export type Flashcard$spacedRepetitionStatesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the SpacedRepetitionCardState
*/
select?: Prisma.SpacedRepetitionCardStateSelect<ExtArgs> | null
/**
* Omit specific fields from the SpacedRepetitionCardState
*/
omit?: Prisma.SpacedRepetitionCardStateOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.SpacedRepetitionCardStateInclude<ExtArgs> | null
where?: Prisma.SpacedRepetitionCardStateWhereInput
orderBy?: Prisma.SpacedRepetitionCardStateOrderByWithRelationInput | Prisma.SpacedRepetitionCardStateOrderByWithRelationInput[]
cursor?: Prisma.SpacedRepetitionCardStateWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.SpacedRepetitionCardStateScalarFieldEnum | Prisma.SpacedRepetitionCardStateScalarFieldEnum[]
}
/**
* Flashcard without action
*/

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

View file

@ -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 }),
});
}

View file

@ -1,5 +0,0 @@
export class ArcadeImportError extends Error {
constructor(message: string, public readonly details: string[] = []) {
super(message);
}
}

View file

@ -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");
});
});

View file

@ -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 };
}

View file

@ -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");
});
});

View file

@ -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,
},
};
}

View file

@ -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");
});
});

View file

@ -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,
})),
],
};
}

View file

@ -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);
}
});
});

View file

@ -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);
}

View file

@ -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");
});
});

View file

@ -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 };
}

View file

@ -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}`,
})),
};
}

View file

@ -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];
}

View file

@ -3,12 +3,6 @@ import { cookies } from "next/headers";
export interface SessionData {
isAuthenticated: boolean;
sessionGeneration?: number;
}
interface PasswordResetSessionData {
nonce?: string;
expiresAt?: string;
}
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() {
const cookieStore = await cookies();
return getIronSession<SessionData>(cookieStore, sessionOptions);
}
export async function createSession(sessionGeneration: number) {
export async function createSession() {
const session = await getSession();
session.isAuthenticated = true;
session.sessionGeneration = sessionGeneration;
await session.save();
}
@ -54,23 +35,3 @@ export async function isAuthenticated() {
const session = await getSession();
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