Compare commits

...

2 commits

Author SHA1 Message Date
c84332d072 Update agents.md, add share icon to main cards
All checks were successful
Automated Container Build / build-and-push (push) Successful in 58s
2026-07-11 19:19:27 -07:00
631dfa5923 New: Ui Redesign 2026-07-11 13:48:02 -07:00
23 changed files with 946 additions and 529 deletions

482
AGENTS.md
View file

@ -1,5 +1,479 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
# Study Desk - AI Contributor Guide
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
This file is the project-specific operating guide for AI agents and human contributors working in this repository.
## Project overview
Study Desk is a self-hosted, single-user study application. It organizes study material by class and supports:
- 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.
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.
When documentation conflicts with implementation, prefer the current source code and `prisma/schema.prisma` over the older planning document or the stock README.
## Mandatory project rules
### Next.js version and documentation
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:
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 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 repository configuration
| 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 |
Important configuration 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.
## Commands and local setup
### Install and run locally
From the repository root:
```bash
npm install
npx prisma generate
npm run dev
```
Then open `http://localhost:3000`.
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:
```powershell
npm.cmd install
npx.cmd prisma generate
npm.cmd run dev
```
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 variables
Copy `.env.example` to `.env` for local work. The current variables are:
| 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`. |
The `.env` file is ignored by Git. Never paste real secrets into source files, documentation, logs, or commits.
### Password initialization behavior
The current implementation initializes the password on the first login when the `Setting` row `admin_password_hash` does not exist:
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.
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.
### Available scripts
```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
```
There is currently no test runner configured in `package.json`. Use the production build plus focused manual/browser verification for UI changes.
### Database workflow
After changing `prisma/schema.prisma`:
```bash
npx prisma generate
npx prisma migrate dev --name describe_the_change
```
Use `npx prisma migrate deploy` for an existing deployment. The Docker entrypoint runs `prisma migrate deploy` before starting the server.
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 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 handlers and services
The preferred API route shape is:
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 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.
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.
### Prisma and database access
- 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.
### Validation and input handling
- 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.
### UI and design system
- 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.
## Domain behavior to preserve
### Flashcards
- 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, each with ordered answer options.
- `MULTIPLE_CHOICE` questions must have exactly one correct option.
- `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
Flashcard import shape:
```json
{
"type": "flashcards",
"deckName": "Deck name",
"description": "Optional description",
"cards": [{ "front": "...", "back": "..." }]
}
```
Quiz import shape:
```json
{
"type": "quiz",
"quizName": "Quiz name",
"description": "Optional description",
"questions": [
{
"type": "multiple_choice",
"prompt": "...",
"category": "topic",
"options": [{ "text": "...", "correct": true }],
"rationale": "..."
}
]
}
```
- 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` 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
- Read `git status --short`.
- 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
- Keep the route/service/component boundaries intact.
- Add validation for new external input.
- 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
Run the narrowest relevant checks, then the full build when possible:
```bash
npm run lint
npm run build
```
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 changes, manually verify at least the affected authenticated route and its corresponding public/shared route if applicable. A useful smoke-test path is:
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 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, follow the existing extensibility pattern:
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 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 giant page component. The existing separation is intentional and is the main maintainability boundary in this project.

BIN
dev.db

Binary file not shown.

View file

@ -90,9 +90,9 @@ export default function DeckStudyPage() {
}
return (
<div className="max-w-4xl mx-auto w-full py-2 md:py-4">
<div className="mx-auto w-full max-w-5xl py-1 md:py-3">
{/* Header */}
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 mb-6">
<div className="mb-7 flex flex-col items-start justify-between gap-4 rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5 md:flex-row md:items-center">
<div>
<button
onClick={() => router.push(`/${classSlug}/flashcards`)}
@ -103,7 +103,8 @@ export default function DeckStudyPage() {
</svg>
Back to decks
</button>
<h1 className="text-xl font-bold text-text-heading">{deck.name}</h1>
<p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Flashcard session</p>
<h1 className="editorial-title mt-1 text-3xl text-text-heading">{deck.name}</h1>
{deck.description && (
<p className="text-sm text-text-secondary mt-1">{deck.description}</p>
)}
@ -126,7 +127,7 @@ export default function DeckStudyPage() {
<div className="flex items-center gap-2 md:gap-4 ml-auto">
<ShareMenu targetType="DECK" contentId={deck.id} classSlug={classSlug} />
<div className="flex bg-bg-surface-alt rounded-lg p-0.5 border border-border-light">
<div className="flex rounded-xl border border-border-light bg-bg-surface-alt p-1">
<button
onClick={() => setView("study")}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${

View file

@ -76,8 +76,8 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
const progressLabel = getProgressLabel(deck);
return (
<div ref={setNodeRef} style={style} className="group flex flex-col bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300 h-full">
<div className="p-4 flex flex-col flex-1 h-full">
<div ref={setNodeRef} style={style} className="group flex h-full flex-col rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)] transition-all duration-300 hover:-translate-y-0.5 hover:border-primary/25 hover:shadow-[var(--shadow-card-hover)]">
<div className="flex h-full flex-1 flex-col p-5">
<div className="flex items-start gap-2">
<div {...attributes} {...listeners} className="mt-1 cursor-grab active:cursor-grabbing text-text-muted hover:text-text-heading">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -85,7 +85,7 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
</svg>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-text-heading mb-1">{deck.name}</h3>
<h3 className="mb-1 text-lg font-extrabold text-text-heading">{deck.name}</h3>
{deck.description && <p className="text-sm text-text-secondary mb-3 line-clamp-2">{deck.description}</p>}
</div>
</div>
@ -135,6 +135,7 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<ShareMenu targetType="DECK" contentId={deck.id} classSlug={classSlug} compact />
</div>
</div>
</div>
@ -369,15 +370,15 @@ export default function FlashcardsPage() {
const activeDeck = decks.find(d => d.id === activeId);
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<div className="pb-20">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-text-heading">Flashcard Decks</h2>
<div className="flex items-center gap-3">
<button onClick={() => setIsCreatingGroup(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-bg-surface-alt text-text-heading font-medium border border-border-light hover:bg-border transition-all duration-200 shadow-sm cursor-pointer">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Collection</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Flashcard decks</h2></div>
<div className="grid grid-cols-2 gap-3 sm:flex">
<button onClick={() => setIsCreatingGroup(true)} className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl border border-border-light bg-bg-surface px-4 font-bold text-text-heading shadow-sm transition-colors hover:bg-bg-surface-alt">
Add Group
</button>
<button onClick={() => setShowImport(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 shadow-sm cursor-pointer">
<button onClick={() => setShowImport(true)} className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl bg-primary px-4 font-bold text-white shadow-sm transition-colors hover:bg-primary-hover">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
@ -387,7 +388,7 @@ export default function FlashcardsPage() {
</div>
{isCreatingGroup && (
<div className="mb-6 p-4 bg-bg-surface rounded-xl border border-primary/20 shadow-sm flex items-center gap-3">
<div className="animate-slide-up mb-6 flex flex-col gap-3 rounded-2xl border border-primary/20 bg-bg-surface p-5 shadow-sm sm:flex-row sm:items-center">
<input
autoFocus
type="text"
@ -407,8 +408,8 @@ export default function FlashcardsPage() {
{editingId && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-bg-surface rounded-xl p-6 w-full max-w-md shadow-lg border border-border">
<h3 className="text-lg font-semibold text-text-heading mb-4">Edit Deck</h3>
<div className="w-full max-w-md rounded-3xl border border-border bg-bg-surface p-6 shadow-[var(--shadow-modal)]">
<h3 className="editorial-title mb-4 text-2xl text-text-heading">Edit deck</h3>
<input
type="text"
value={editName}
@ -443,7 +444,7 @@ export default function FlashcardsPage() {
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
<div className="space-y-8">
{groupedDecks.map(group => (
<div key={group.id} className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
<div key={group.id} className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
<div className={`flex items-center justify-between ${collapsedGroups[group.id] ? "" : "mb-4"}`}>
{editingGroupId === group.id ? (
<div className="flex items-center gap-2">
@ -456,7 +457,7 @@ export default function FlashcardsPage() {
<button onClick={() => toggleGroup(group.id)} className="p-1 rounded text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer" title={collapsedGroups[group.id] ? "Expand" : "Collapse"}>
<svg className={`w-5 h-5 transition-transform duration-200 ${collapsedGroups[group.id] ? '-rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</button>
<h3 className="text-lg font-semibold text-text-heading">{group.name}</h3>
<h3 className="text-lg font-extrabold text-text-heading">{group.name}</h3>
</div>
)}
@ -484,7 +485,7 @@ export default function FlashcardsPage() {
))}
{/* Uncategorized */}
<div className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
<div className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
<div className={`flex items-center gap-2 ${collapsedGroups["uncategorized"] ? "" : "mb-4"}`}>
<button onClick={() => toggleGroup("uncategorized")} className="p-1 rounded text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer" title={collapsedGroups["uncategorized"] ? "Expand" : "Collapse"}>
<svg className={`w-5 h-5 transition-transform duration-200 ${collapsedGroups["uncategorized"] ? '-rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>

View file

@ -11,7 +11,7 @@ export default async function ClassLayout(props: LayoutProps<"/[classSlug]">) {
}
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="app-page">
{/* Dynamic Header & Tabs */}
<ClassHeader classSlug={classSlug} className={classData.name} />

View file

@ -124,9 +124,9 @@ export default function QuizStudyPage() {
}
return (
<div className="max-w-4xl mx-auto w-full py-2 md:py-4">
<div className="mx-auto w-full max-w-5xl py-1 md:py-3">
{/* Header */}
<div className="flex flex-col md:flex-row items-start justify-between gap-4 mb-6">
<div className="mb-7 flex flex-col items-start justify-between gap-4 rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5 md:flex-row">
<div>
<button
onClick={() => router.push(`/${classSlug}/quizzes`)}
@ -142,7 +142,7 @@ export default function QuizStudyPage() {
onClick={() => setShowTopics(!showTopics)}
className="flex items-center gap-2 group cursor-pointer text-left focus:outline-none mb-2"
>
<h1 className="text-3xl font-bold font-serif text-text-heading group-hover:text-primary transition-colors">
<h1 className="editorial-title text-3xl text-text-heading transition-colors group-hover:text-primary sm:text-4xl">
{quiz.name}
</h1>
<svg
@ -180,7 +180,7 @@ export default function QuizStudyPage() {
<ShareMenu targetType="QUIZ" contentId={quiz.id} classSlug={classSlug} />
{attempts.length > 0 && (
<div className="flex bg-bg-surface-alt rounded-lg p-0.5 border border-border-light">
<div className="flex rounded-xl border border-border-light bg-bg-surface-alt p-1">
<button
onClick={() => setView("history")}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${

View file

@ -54,8 +54,8 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) {
};
return (
<div ref={setNodeRef} style={style} className="group bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300">
<div className="p-4 flex flex-col flex-1 h-full">
<div ref={setNodeRef} style={style} className="group rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)] transition-all duration-300 hover:-translate-y-0.5 hover:border-primary/25 hover:shadow-[var(--shadow-card-hover)]">
<div className="flex h-full flex-1 flex-col p-5">
<div className="flex items-start gap-2">
<div {...attributes} {...listeners} className="mt-1 cursor-grab active:cursor-grabbing text-text-muted hover:text-text-heading">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -63,7 +63,7 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) {
</svg>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-text-heading mb-1">{quiz.name}</h3>
<h3 className="mb-1 text-lg font-extrabold text-text-heading">{quiz.name}</h3>
{quiz.description && <p className="text-sm text-text-secondary mb-3 line-clamp-2">{quiz.description}</p>}
</div>
</div>
@ -93,6 +93,7 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<ShareMenu targetType="QUIZ" contentId={quiz.id} classSlug={classSlug} compact />
</div>
</div>
</div>
@ -343,15 +344,15 @@ export default function QuizzesPage() {
const activeQuiz = quizzes.find(q => q.id === activeId);
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<div className="pb-20">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-text-heading">Quizzes</h2>
<div className="flex items-center gap-3">
<button onClick={() => setIsCreatingGroup(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-bg-surface-alt text-text-heading font-medium border border-border-light hover:bg-border transition-all duration-200 shadow-sm cursor-pointer">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Practice</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Practice quizzes</h2></div>
<div className="grid grid-cols-2 gap-3 sm:flex">
<button onClick={() => setIsCreatingGroup(true)} className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl border border-border-light bg-bg-surface px-4 font-bold text-text-heading shadow-sm transition-colors hover:bg-bg-surface-alt">
Add Group
</button>
<button onClick={() => setShowImport(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 shadow-sm cursor-pointer">
<button onClick={() => setShowImport(true)} className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl bg-primary px-4 font-bold text-white shadow-sm transition-colors hover:bg-primary-hover">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
@ -361,7 +362,7 @@ export default function QuizzesPage() {
</div>
{isCreatingGroup && (
<div className="mb-6 p-4 bg-bg-surface rounded-xl border border-primary/20 shadow-sm flex items-center gap-3">
<div className="animate-slide-up mb-6 flex flex-col gap-3 rounded-2xl border border-primary/20 bg-bg-surface p-5 shadow-sm sm:flex-row sm:items-center">
<input
autoFocus
type="text"
@ -382,8 +383,8 @@ export default function QuizzesPage() {
{/* Editing quiz modal/inline - simplified as a modal-like overlay for simplicity when editing */}
{editingId && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-bg-surface rounded-xl p-6 w-full max-w-md shadow-lg border border-border">
<h3 className="text-lg font-semibold text-text-heading mb-4">Edit Quiz</h3>
<div className="w-full max-w-md rounded-3xl border border-border bg-bg-surface p-6 shadow-[var(--shadow-modal)]">
<h3 className="editorial-title mb-4 text-2xl text-text-heading">Edit quiz</h3>
<input
type="text"
value={editName}
@ -418,7 +419,7 @@ export default function QuizzesPage() {
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
<div className="space-y-8">
{groupedQuizzes.map(group => (
<div key={group.id} className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
<div key={group.id} className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
<div className={`flex items-center justify-between ${collapsedGroups[group.id] ? "" : "mb-4"}`}>
{editingGroupId === group.id ? (
<div className="flex items-center gap-2">
@ -431,7 +432,7 @@ export default function QuizzesPage() {
<button onClick={() => toggleGroup(group.id)} className="p-1 rounded text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer" title={collapsedGroups[group.id] ? "Expand" : "Collapse"}>
<svg className={`w-5 h-5 transition-transform duration-200 ${collapsedGroups[group.id] ? '-rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</button>
<h3 className="text-lg font-semibold text-text-heading">{group.name}</h3>
<h3 className="text-lg font-extrabold text-text-heading">{group.name}</h3>
</div>
)}
@ -459,7 +460,7 @@ export default function QuizzesPage() {
))}
{/* Uncategorized */}
<div className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
<div className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
<div className={`flex items-center gap-2 ${collapsedGroups["uncategorized"] ? "" : "mb-4"}`}>
<button onClick={() => toggleGroup("uncategorized")} className="p-1 rounded text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer" title={collapsedGroups["uncategorized"] ? "Expand" : "Collapse"}>
<svg className={`w-5 h-5 transition-transform duration-200 ${collapsedGroups["uncategorized"] ? '-rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>

View file

@ -13,9 +13,9 @@ export default async function ProtectedLayout({
}
return (
<div className="min-h-screen flex flex-col">
<div className="min-h-screen">
<Navbar />
<main className="flex-1">{children}</main>
<main className="min-h-screen md:ml-60">{children}</main>
</div>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
import Link from "next/link";
interface ClassItem {
@ -10,6 +10,8 @@ interface ClassItem {
_count: { decks: number; quizSets: number };
}
const accents = ["#4f46e5", "#f97360", "#25845f", "#c27718", "#8b5cf6", "#0284c7"];
export default function HomePage() {
const [classes, setClasses] = useState<ClassItem[]>([]);
const [loading, setLoading] = useState(true);
@ -20,256 +22,128 @@ export default function HomePage() {
const [editName, setEditName] = useState("");
const [savingEdit, setSavingEdit] = useState(false);
useEffect(() => {
fetchClasses();
}, []);
useEffect(() => { fetchClasses(); }, []);
async function fetchClasses() {
try {
const res = await fetch("/api/classes");
const data = await res.json();
setClasses(data);
} finally {
setLoading(false);
}
setClasses(await res.json());
} finally { setLoading(false); }
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!newName.trim()) return;
setCreating(true);
try {
const res = await fetch("/api/classes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim() }),
});
if (res.ok) {
setNewName("");
setShowCreate(false);
fetchClasses();
}
} finally {
setCreating(false);
}
const res = await fetch("/api/classes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newName.trim() }) });
if (res.ok) { setNewName(""); setShowCreate(false); fetchClasses(); }
} finally { setCreating(false); }
}
async function handleDelete(id: string, name: string) {
if (!confirm(`Delete "${name}" and all its decks and quizzes?`)) return;
await fetch(`/api/classes/${id}`, { method: "DELETE" });
fetchClasses();
}
async function handleSaveEdit(id: string) {
if (!editName.trim()) {
setEditingId(null);
return;
}
if (!editName.trim()) { setEditingId(null); return; }
setSavingEdit(true);
try {
const res = await fetch(`/api/classes/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editName.trim() }),
});
if (res.ok) {
setEditingId(null);
fetchClasses();
}
} finally {
setSavingEdit(false);
}
const res = await fetch(`/api/classes/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: editName.trim() }) });
if (res.ok) { setEditingId(null); fetchClasses(); }
} finally { setSavingEdit(false); }
}
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="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-text-heading">Your Classes</h1>
<p className="text-text-secondary mt-1">
Select a class to study flashcards or take quizzes
</p>
<div className="app-page">
<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>
<button
onClick={() => setShowCreate(!showCreate)}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 shadow-sm cursor-pointer"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Class
</button>
</div>
{/* Create class inline form */}
{showCreate && (
<div className="bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] p-5 mb-6 animate-slide-up">
<form onSubmit={handleCreate} className="flex gap-3">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="e.g. PHA 109, Anatomy, Nursing Fundamentals..."
autoFocus
className="flex-1 px-3.5 py-2.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200"
/>
<button
type="submit"
disabled={creating || !newName.trim()}
className="px-5 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover disabled:opacity-50 transition-all duration-200 cursor-pointer"
>
{creating ? "Creating..." : "Create"}
</button>
<button
type="button"
onClick={() => {
setShowCreate(false);
setNewName("");
}}
className="px-4 py-2.5 rounded-lg border border-border text-text-secondary hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
>
Cancel
</button>
</form>
</div>
)}
{/* Loading state */}
{loading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div
key={i}
className="bg-bg-surface rounded-xl border border-border-light p-6 animate-subtle-pulse"
>
<div className="h-5 bg-bg-surface-alt rounded w-2/3 mb-3" />
<div className="h-4 bg-bg-surface-alt rounded w-1/3" />
<div 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">
<div className="mb-4">
<h2 className="editorial-title text-2xl text-text-heading">Create a class</h2>
<p className="mt-1 text-sm text-text-secondary">Give this study space a short, memorable name.</p>
</div>
<form onSubmit={handleCreate} className="flex flex-col gap-3 sm:flex-row">
<input value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="e.g. Pathophysiology" autoFocus className="min-h-12 flex-1 rounded-xl border border-border bg-bg-surface-alt/60 px-4 text-text-heading placeholder:text-text-muted focus:border-primary" />
<button type="submit" disabled={creating || !newName.trim()} className="min-h-12 rounded-xl bg-primary px-6 text-sm font-bold text-white hover:bg-primary-hover disabled:opacity-45">{creating ? "Creating…" : "Create class"}</button>
<button type="button" onClick={() => { setShowCreate(false); setNewName(""); }} className="min-h-12 rounded-xl border border-border px-5 text-sm font-bold text-text-secondary hover:bg-bg-surface-alt">Cancel</button>
</form>
</section>
)}
{/* Empty state */}
{!loading && classes.length === 0 && (
<div className="text-center py-16">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-bg-callout mb-4">
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h2 className="text-lg font-semibold text-text-heading mb-1">
No classes yet
</h2>
<p className="text-text-secondary mb-4">
Create your first class to start studying
</p>
<button
onClick={() => setShowCreate(true)}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Your First Class
</button>
<div className="mb-5 flex items-end justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-[0.18em] text-primary">Study spaces</p>
<h2 className="editorial-title mt-1 text-3xl text-text-heading">Your classes</h2>
</div>
<p className="hidden text-sm text-text-muted sm:block">Flashcards and quizzes, organized by class.</p>
</div>
{loading && (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{[1, 2, 3].map((i) => <div key={i} className="h-52 animate-subtle-pulse rounded-2xl border border-border-light bg-bg-surface" />)}
</div>
)}
{!loading && classes.length === 0 && (
<div className="paper-grid rounded-[2rem] border border-dashed border-border bg-bg-surface px-6 py-16 text-center">
<div className="mx-auto mb-5 grid h-14 w-14 place-items-center rounded-2xl bg-bg-callout text-2xl text-primary">+</div>
<h2 className="editorial-title text-2xl text-text-heading">Your desk is ready</h2>
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-text-secondary">Create your first class, then add flashcards or practice quizzes.</p>
<button onClick={() => setShowCreate(true)} className="mt-6 min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white hover:bg-primary-hover">Create your first class</button>
</div>
)}
{/* Class grid */}
{!loading && classes.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{classes.map((cls) => (
<div
key={cls.id}
className="group relative bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300"
>
<Link
href={`/${cls.slug}`}
className="block p-6"
>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{classes.map((cls, index) => (
<article key={cls.id} className="group relative overflow-hidden rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)] transition-all hover:-translate-y-1 hover:shadow-[var(--shadow-card-hover)]">
<div className="h-1.5" style={{ backgroundColor: accents[index % accents.length] }} />
<Link href={`/${cls.slug}`} className="block min-h-48 p-5 sm:p-6">
<div className="mb-8 flex items-start justify-between gap-4">
<div className="grid h-11 w-11 place-items-center rounded-xl bg-bg-surface-alt text-sm font-extrabold text-text-heading">{cls.name.slice(0, 2).toUpperCase()}</div>
<span className="text-xl text-text-muted transition-transform group-hover:translate-x-1" aria-hidden></span>
</div>
{editingId === cls.id ? (
<input
type="text"
value={editName}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
handleSaveEdit(cls.id);
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setEditingId(null);
}
}}
onBlur={() => handleSaveEdit(cls.id)}
autoFocus
disabled={savingEdit}
className="w-[calc(100%-2.5rem)] text-lg font-semibold text-text-heading px-2 py-1 -ml-2 rounded bg-bg-surface-alt border border-primary/30 focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
/>
) : (
<h3 className="text-lg font-semibold text-text-heading group-hover:text-primary transition-colors">
{cls.name}
</h3>
)}
<div className="flex gap-4 mt-3">
<span className="inline-flex items-center gap-1.5 text-sm text-text-secondary">
<svg className="w-4 h-4 text-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
{cls._count.decks} {cls._count.decks === 1 ? "deck" : "decks"}
</span>
<span className="inline-flex items-center gap-1.5 text-sm text-text-secondary">
<svg className="w-4 h-4 text-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{cls._count.quizSets} {cls._count.quizSets === 1 ? "quiz" : "quizzes"}
</span>
<input value={editName} onClick={(e) => { e.preventDefault(); e.stopPropagation(); }} onChange={(e) => setEditName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleSaveEdit(cls.id); } else if (e.key === "Escape") setEditingId(null); }} onBlur={() => handleSaveEdit(cls.id)} autoFocus disabled={savingEdit} className="w-full rounded-lg border border-primary bg-bg-surface-alt px-2 py-1 text-lg font-bold text-text-heading" />
) : <h3 className="text-xl font-extrabold text-text-heading group-hover:text-primary">{cls.name}</h3>}
<div className="mt-3 flex flex-wrap gap-2 text-xs font-semibold text-text-secondary">
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1">{cls._count.decks} {cls._count.decks === 1 ? "deck" : "decks"}</span>
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1">{cls._count.quizSets} {cls._count.quizSets === 1 ? "quiz" : "quizzes"}</span>
</div>
</Link>
{/* Actions */}
<div className="absolute top-3 right-3 flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-all duration-200">
{/* Delete button */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDelete(cls.id, cls.name);
}}
className="p-1.5 rounded-lg text-text-muted hover:text-error hover:bg-error-bg transition-colors cursor-pointer"
title="Delete class"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
{/* Edit button */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setEditingId(cls.id);
setEditName(cls.name);
}}
className="p-1.5 rounded-lg text-text-muted hover:text-primary hover:bg-primary/10 transition-colors cursor-pointer"
title="Rename class"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
<div className="absolute right-3 top-4 flex gap-1 rounded-xl border border-border-light bg-bg-surface p-1 opacity-100 shadow-sm sm:opacity-0 sm:group-hover:opacity-100">
<button onClick={() => { setEditingId(cls.id); setEditName(cls.name); }} className="grid h-9 w-9 place-items-center rounded-lg text-text-muted hover:bg-bg-callout hover:text-primary" title="Rename class" aria-label={`Rename ${cls.name}`}></button>
<button onClick={() => handleDelete(cls.id, cls.name)} className="grid h-9 w-9 place-items-center rounded-lg text-text-muted hover:bg-error-bg hover:text-error" title="Delete class" aria-label={`Delete ${cls.name}`}>×</button>
</div>
</div>
</article>
))}
</div>
)}

View file

@ -2,64 +2,104 @@
@layer base {
:root {
--theme-bg-base: #f0f4f2;
--theme-bg-surface: #ffffff;
--theme-bg-surface-alt: #f4f7f5;
--theme-bg-callout: #e8f4f0;
--theme-primary: #2a7d7d;
--theme-primary-hover: #1f6363;
--theme-primary-light: #3a9e9e;
--theme-text-heading: #1a2f2f;
--theme-text-body: #2d4a4a;
--theme-text-secondary: #4a6a6a;
--theme-text-muted: #7a9a9a;
--theme-border: #c8d8d0;
--theme-border-light: #dce8e2;
--theme-success: #2d8a4e;
--theme-success-bg: #e8f5ed;
--theme-error: #c44c4c;
--theme-error-bg: #fce8e8;
--theme-badge-bg: #e0eeea;
--theme-badge-text: #2a6a5a;
--theme-danger: #dc3545;
--theme-danger-hover: #c82333;
--theme-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--theme-shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
--theme-shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.15), 0 8px 20px rgba(0, 0, 0, 0.1);
--theme-bg-base: #f5f2ea;
--theme-bg-surface: #fffdf8;
--theme-bg-surface-alt: #eeeae0;
--theme-bg-callout: #ebe9ff;
--theme-primary: #4f46e5;
--theme-primary-hover: #3f37c9;
--theme-primary-light: #756ef0;
--theme-accent: #f97360;
--theme-text-heading: #172033;
--theme-text-body: #344054;
--theme-text-secondary: #667085;
--theme-text-muted: #8b91a0;
--theme-border: #d7d2c7;
--theme-border-light: #e7e2d8;
--theme-success: #25845f;
--theme-success-bg: #e3f4ec;
--theme-error: #c44747;
--theme-error-bg: #fae8e5;
--theme-badge-bg: #e8e5ff;
--theme-badge-text: #4840bb;
--theme-danger: #c44747;
--theme-danger-hover: #a93636;
--theme-shadow-card: 0 1px 2px rgba(23, 32, 51, 0.04), 0 8px 24px rgba(23, 32, 51, 0.055);
--theme-shadow-card-hover: 0 2px 4px rgba(23, 32, 51, 0.05), 0 18px 40px rgba(23, 32, 51, 0.1);
--theme-shadow-modal: 0 28px 80px rgba(23, 32, 51, 0.22);
}
:root.dark {
--theme-bg-base: #121212;
--theme-bg-surface: #1e1e1e;
--theme-bg-surface-alt: #2d2d2d;
--theme-bg-callout: #1a2723;
--theme-primary: #4ab2b2;
--theme-primary-hover: #5ec7c7;
--theme-primary-light: #6cd1d1;
--theme-text-heading: #f9fafb;
--theme-text-body: #e5e7eb;
--theme-text-secondary: #9ca3af;
--theme-text-muted: #6b7280;
--theme-border: #374151;
--theme-border-light: #2d3748;
--theme-success: #4ade80;
--theme-success-bg: #064e3b;
--theme-error: #f87171;
--theme-error-bg: #7f1d1d;
--theme-badge-bg: #1f2937;
--theme-badge-text: #5ec7c7;
--theme-danger: #ef4444;
--theme-danger-hover: #f87171;
--theme-bg-base: #0d1117;
--theme-bg-surface: #151b23;
--theme-bg-surface-alt: #1c2430;
--theme-bg-callout: #25234a;
--theme-primary: #8b83ff;
--theme-primary-hover: #a49eff;
--theme-primary-light: #b2adff;
--theme-accent: #ff8a75;
--theme-text-heading: #f6f3ec;
--theme-text-body: #d8dde6;
--theme-text-secondary: #aab3c0;
--theme-text-muted: #7f8998;
--theme-border: #354050;
--theme-border-light: #293342;
--theme-success: #58c596;
--theme-success-bg: #173c30;
--theme-error: #ff8177;
--theme-error-bg: #472424;
--theme-badge-bg: #2d2959;
--theme-badge-text: #bbb7ff;
--theme-danger: #ff8177;
--theme-danger-hover: #ffa098;
--theme-shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3), 0 10px 30px rgba(0, 0, 0, 0.18);
--theme-shadow-card-hover: 0 2px 4px rgba(0, 0, 0, 0.36), 0 20px 44px rgba(0, 0, 0, 0.28);
--theme-shadow-modal: 0 30px 90px rgba(0, 0, 0, 0.55);
}
--theme-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.2);
--theme-shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.6), 0 2px 4px rgba(0, 0, 0, 0.4);
--theme-shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.5), 0 8px 20px rgba(0, 0, 0, 0.3);
* {
border-color: var(--theme-border-light);
}
html {
background: var(--theme-bg-base);
scroll-behavior: smooth;
}
body {
min-width: 320px;
background:
radial-gradient(circle at 88% -10%, color-mix(in srgb, var(--theme-primary) 9%, transparent), transparent 32rem),
var(--theme-bg-base);
color: var(--theme-text-body);
font-family: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
button, a, input, textarea, select {
outline-color: var(--theme-primary);
}
:focus-visible {
outline: 3px solid color-mix(in srgb, var(--theme-primary) 45%, transparent);
outline-offset: 3px;
}
button, [role="button"], a {
-webkit-tap-highlight-color: transparent;
}
button:not(:disabled), [role="button"], a {
cursor: pointer;
}
::selection {
background: color-mix(in srgb, var(--theme-primary) 24%, transparent);
}
}
@theme inline {
/* ── Sage / Teal palette mapped from CSS variables ── */
--color-bg-base: var(--theme-bg-base);
--color-bg-surface: var(--theme-bg-surface);
--color-bg-surface-alt: var(--theme-bg-surface-alt);
@ -67,6 +107,7 @@
--color-primary: var(--theme-primary);
--color-primary-hover: var(--theme-primary-hover);
--color-primary-light: var(--theme-primary-light);
--color-accent: var(--theme-accent);
--color-text-heading: var(--theme-text-heading);
--color-text-body: var(--theme-text-body);
--color-text-secondary: var(--theme-text-secondary);
@ -81,123 +122,72 @@
--color-badge-text: var(--theme-badge-text);
--color-danger: var(--theme-danger);
--color-danger-hover: var(--theme-danger-hover);
/* Typography */
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
/* Shadows */
--font-sans: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
--font-serif: var(--font-newsreader), ui-serif, Georgia, serif;
--shadow-card: var(--theme-shadow-card);
--shadow-card-hover: var(--theme-shadow-card-hover);
--shadow-modal: var(--theme-shadow-modal);
/* Animations */
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-spring: cubic-bezier(0.22, 1, 0.36, 1);
}
/* ── Base Styles ── */
body {
background: var(--color-bg-base);
color: var(--color-text-body);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--color-border); border: 2px solid transparent; border-radius: 999px; background-clip: padding-box; }
.app-page {
width: 100%;
max-width: 1280px;
margin-inline: auto;
padding: 2rem clamp(1rem, 3vw, 3rem) 5rem;
}
/* ── Scrollbar Styling ── */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
.editorial-title {
font-family: var(--font-newsreader), Georgia, serif;
font-weight: 600;
letter-spacing: -0.035em;
}
/* ── Card flip animation ── */
.perspective-1000 {
perspective: 1000px;
.paper-grid {
background-image: radial-gradient(color-mix(in srgb, var(--theme-text-muted) 18%, transparent) 0.7px, transparent 0.7px);
background-size: 18px 18px;
}
.preserve-3d {
transform-style: preserve-3d;
}
.perspective-1000 { perspective: 1000px; }
.preserve-3d { transform-style: preserve-3d; }
.backface-hidden { backface-visibility: hidden; }
.rotate-y-180 { transform: rotateY(180deg); }
.rotate-x-180 { transform: rotateX(180deg); }
.backface-hidden {
backface-visibility: hidden;
}
@keyframes swipeLeft { to { transform: translateX(-44px) rotate(-6deg); opacity: 0; } }
@keyframes swipeRight { to { transform: translateX(44px) rotate(6deg); opacity: 0; } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(14px) scale(.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes subtlePulse { 0%, 100% { opacity: 1; } 50% { opacity: .58; } }
.rotate-y-180 {
transform: rotateY(180deg);
}
.animate-swipe-left { animation: swipeLeft .36s var(--ease-spring) forwards; }
.animate-swipe-right { animation: swipeRight .36s var(--ease-spring) forwards; }
.animate-fade-in { animation: fadeIn .2s ease-out; }
.animate-slide-up { animation: slideUp .32s var(--ease-spring); }
.animate-subtle-pulse { animation: subtlePulse 1.7s ease-in-out infinite; }
.rotate-x-180 {
transform: rotateX(180deg);
}
/* ── Swipe animations ── */
@keyframes swipeLeft {
0% { transform: translateX(0) rotate(0deg); opacity: 1; }
100% { transform: translateX(-40px) rotate(-8deg); opacity: 0; }
}
@keyframes swipeRight {
0% { transform: translateX(0) rotate(0deg); opacity: 1; }
100% { transform: translateX(40px) rotate(8deg); opacity: 0; }
}
.animate-swipe-left {
animation: swipeLeft 0.4s var(--ease-spring) forwards;
}
.animate-swipe-right {
animation: swipeRight 0.4s var(--ease-spring) forwards;
}
/* ── Modal animation ── */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.animate-fade-in {
animation: fadeIn 0.2s ease-out;
}
.animate-slide-up {
animation: slideUp 0.3s var(--ease-spring);
}
/* ── Subtle pulse for loading states ── */
@keyframes subtlePulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.animate-subtle-pulse {
animation: subtlePulse 2s ease-in-out infinite;
}
/* ── Markdown content styles ── */
.markdown-content h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.5rem; color: var(--color-text-heading); }
.markdown-content h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--color-text-heading); }
.markdown-content h3 { font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--color-text-heading); }
.markdown-content p { margin-bottom: 0.75rem; line-height: 1.7; }
.markdown-content ul { list-style: disc inside; padding-left: 0; margin-bottom: 0.75rem; text-align: inherit; }
.markdown-content ol { list-style: decimal inside; padding-left: 0; margin-bottom: 0.75rem; text-align: inherit; }
.markdown-content li { margin-bottom: 0.25rem; line-height: 1.6; }
.markdown-content strong { font-weight: 600; color: var(--color-text-heading); }
.markdown-content code { background: var(--color-bg-surface-alt); padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.875rem; }
.markdown-content table { border-collapse: collapse; width: 100%; margin-bottom: 0.75rem; }
.markdown-content th { background: var(--color-bg-surface-alt); font-weight: 600; text-align: left; padding: 0.5rem 0.75rem; border: 1px solid var(--color-border-light); }
.markdown-content td { padding: 0.5rem 0.75rem; border: 1px solid var(--color-border-light); }
.markdown-content h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: .5rem; color: var(--color-text-heading); }
.markdown-content h2 { font-size: 1.25rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }
.markdown-content h3 { font-size: 1.125rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }
.markdown-content p { margin-bottom: .75rem; line-height: 1.72; }
.markdown-content ul { list-style: disc; padding-left: 1.35rem; margin-bottom: .75rem; }
.markdown-content ol { list-style: decimal; padding-left: 1.35rem; margin-bottom: .75rem; }
.markdown-content li { margin-bottom: .25rem; line-height: 1.65; }
.markdown-content strong { font-weight: 700; color: var(--color-text-heading); }
.markdown-content code { background: var(--color-bg-surface-alt); padding: .15rem .4rem; border-radius: .35rem; font-size: .875rem; }
.markdown-content table { border-collapse: collapse; width: 100%; margin-bottom: .75rem; }
.markdown-content th { background: var(--color-bg-surface-alt); font-weight: 650; text-align: left; padding: .6rem .8rem; border: 1px solid var(--color-border-light); }
.markdown-content td { padding: .6rem .8rem; border: 1px solid var(--color-border-light); }
.markdown-content *:last-child { margin-bottom: 0; }
@media (max-width: 767px) {
.app-page { padding-top: 1.25rem; padding-bottom: 6rem; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}

View file

@ -1,16 +1,23 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { Manrope, Newsreader } from "next/font/google";
import Script from "next/script";
import "./globals.css";
const inter = Inter({
variable: "--font-inter",
const manrope = Manrope({
variable: "--font-manrope",
subsets: ["latin"],
display: "swap",
});
const newsreader = Newsreader({
variable: "--font-newsreader",
subsets: ["latin"],
display: "swap",
});
export const metadata: Metadata = {
title: "Study App",
description: "Practice exams and quizzes",
title: "Study Desk",
description: "A focused workspace for flashcards and practice quizzes",
};
export default function RootLayout({
@ -19,23 +26,21 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" className={`${inter.variable} h-full`} suppressHydrationWarning>
<head>
<script
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 {
if (localStorage.theme === 'dark') {
const savedTheme = localStorage.theme
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
} catch (_) {}
`,
}}
/>
</head>
<body className="min-h-full flex flex-col font-sans antialiased">
`}
</Script>
{children}
</body>
</html>

View file

@ -2,6 +2,7 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
export default function LoginPage() {
const [password, setPassword] = useState("");
@ -55,37 +56,28 @@ export default function LoginPage() {
}
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-sm animate-fade-in">
<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>
<div className="relative w-full max-w-md animate-fade-in">
{/* Logo / Title area */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary/10 mb-4">
<svg
className="w-8 h-8 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-text-heading">
{isSetup ? "Welcome to Study App!" : "Study App"}
<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">
{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."
: "Enter your password to continue"}
: "Open your notes, decks, and practice quizzes."}
</p>
</div>
{/* Login card */}
<div className="bg-bg-surface rounded-xl shadow-[var(--shadow-card)] border border-border-light p-6">
<div className="rounded-[2rem] border border-border-light bg-bg-surface/90 p-6 shadow-[var(--shadow-modal)] backdrop-blur sm:p-8">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
@ -101,7 +93,7 @@ export default function LoginPage() {
onChange={(e) => setPassword(e.target.value)}
placeholder={isSetup ? "Create a new password" : "Enter your password"}
autoFocus
className="w-full px-3.5 py-2.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200"
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>
@ -127,7 +119,7 @@ export default function LoginPage() {
<button
type="submit"
disabled={loading || !password}
className="w-full py-2.5 px-4 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 cursor-pointer"
className="min-h-12 w-full rounded-xl bg-primary px-4 font-bold text-white transition-colors hover:bg-primary-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? (
<span className="inline-flex items-center gap-2">

View file

@ -1,5 +1,5 @@
import { getShareLink } from "@/services/shareService";
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
import Link from "next/link";
import { SharedViewer } from "./SharedViewer";
@ -27,6 +27,16 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
notFound();
}
// Older inherited-share URLs used a group token with a deck/quiz path.
// They did not include an item ID, so preserve them by sending visitors to
// the owning group where the intended item can be selected.
if ((type === "flashcards" || type === "quizzes") && link.group) {
if (link.group.class.slug !== classSlug) {
notFound();
}
redirect(`/shared/${classSlug}/groups/${token}`);
}
// Validate that the link matches the URL structure
if (type === "flashcards" && !link.deck) notFound();
if (type === "quizzes" && !link.quizSet) notFound();
@ -62,13 +72,14 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
return (
<div className="min-h-screen bg-bg-base flex flex-col">
{/* Read-only Header */}
<header className="bg-bg-surface border-b border-border-light shadow-sm sticky top-0 z-10">
<header className="sticky top-0 z-10 border-b border-border-light bg-bg-surface/88 shadow-sm backdrop-blur-xl">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-14">
<div className="flex items-center gap-2.5 text-text-heading font-semibold">
<div className="flex items-center gap-2.5 font-semibold text-text-heading">
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<span className="grid h-8 w-8 place-items-center rounded-xl bg-primary text-xs font-black text-white">S</span>
Shared {type === "groups" ? "Group" : (type === "flashcards" ? "Deck" : "Quiz")}
</div>
@ -92,7 +103,7 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
{/* Main Content */}
<main className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mx-auto max-w-5xl px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
{type === "groups" && !itemId ? (
<SharedGroupViewer data={targetData} />
) : (

View file

@ -79,7 +79,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
</span>
<button
onClick={() => setShowAdd(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl bg-primary px-4 text-sm font-bold text-white transition-colors hover:bg-primary-hover"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
@ -92,7 +92,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
{cards.map((card, index) => (
<div
key={card.id}
className="bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] overflow-hidden"
className="overflow-hidden rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)]"
>
{editingId === card.id ? (
<div className="p-4 space-y-3">
@ -136,7 +136,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
</div>
) : (
<div className="flex items-stretch">
<div className="flex-1 p-4 grid grid-cols-2 gap-4">
<div className="grid flex-1 grid-cols-1 gap-4 p-4 sm:grid-cols-2">
<div>
<span className="block text-xs font-medium text-text-muted uppercase tracking-wider mb-1">
Front

View file

@ -277,13 +277,13 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
if (completed) {
return (
<div className="flex flex-col items-center justify-center py-16">
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-8 max-w-md w-full text-center">
<div className="w-full max-w-md rounded-3xl border border-border-light bg-bg-surface p-8 text-center shadow-[var(--shadow-card)]">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-success-bg mb-4">
<svg className="w-7 h-7 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-lg font-semibold text-text-heading mb-2">Set Complete!</h3>
<h3 className="editorial-title mb-2 text-3xl text-text-heading">Set complete</h3>
<div className="flex justify-center gap-6 mb-6">
<div className="text-center">
@ -334,7 +334,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
return (
<div className="flex flex-col items-center">
{/* Running tally */}
<div className="w-full max-w-4xl mb-6">
<div className="mb-5 w-full max-w-4xl rounded-xl border border-border-light bg-bg-surface/70 px-4 py-3">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-3">
<span className="text-success font-medium">{correctCount} </span>
@ -371,16 +371,16 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
>
<div
key={currentCard.id}
className={`relative w-full min-h-[450px] md:min-h-[550px] preserve-3d ${!swipeClass ? "transition-transform duration-300" : ""} ${
className={`relative w-full min-h-[420px] md:min-h-[560px] preserve-3d ${!swipeClass ? "transition-transform duration-300" : ""} ${
isFlipped ? "rotate-x-180" : ""
}`}
>
{/* Front */}
<div className="absolute inset-0 backface-hidden bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-8 md:p-12 flex flex-col items-center justify-center">
<div className="paper-grid absolute inset-0 flex flex-col items-center justify-center rounded-[2rem] border border-border-light bg-bg-surface p-7 shadow-[var(--shadow-card-hover)] backface-hidden md:p-12">
<span className="absolute top-3 right-4 text-xs font-medium text-text-muted uppercase tracking-wider">
Front
</span>
<div className="markdown-content text-center text-xl md:text-3xl text-text-heading leading-relaxed w-full">
<div className="markdown-content w-full text-center font-serif text-2xl font-semibold leading-relaxed text-text-heading md:text-4xl">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{currentCard.front}
</ReactMarkdown>
@ -391,7 +391,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
</div>
{/* Back */}
<div className="absolute inset-0 backface-hidden rotate-x-180 bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-8 md:p-12 flex flex-col items-center justify-center">
<div className="paper-grid absolute inset-0 flex rotate-x-180 flex-col items-center justify-center rounded-[2rem] border border-primary/25 bg-bg-surface p-7 shadow-[var(--shadow-card-hover)] backface-hidden md:p-12">
<span className="absolute top-3 right-4 text-xs font-medium text-text-muted uppercase tracking-wider">
Back
</span>
@ -437,12 +437,12 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
<div className="w-full mt-6">
{/* Primary Action Row (Grading) */}
<div className={`flex items-center justify-center gap-4 min-h-[52px] mb-4 relative z-10 ${!hasFlippedOnce ? 'pointer-events-none' : ''}`}>
<div className={`relative z-10 mb-4 flex min-h-[56px] items-center justify-center gap-3 sm:gap-4 ${!hasFlippedOnce ? 'pointer-events-none' : ''}`}>
{hasFlippedOnce && (
<div className="flex items-center gap-2 md:gap-4 animate-fade-in pointer-events-auto">
<button
onClick={() => gradeCard("missed")}
className="flex items-center gap-2 px-6 py-3 rounded-xl border-2 border-error/30 text-error font-medium hover:bg-error-bg transition-all duration-200 cursor-pointer bg-bg-base/80 backdrop-blur"
className="flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border-2 border-error/30 bg-bg-surface px-4 font-bold text-error transition-colors hover:bg-error-bg sm:flex-none sm:px-7"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@ -451,7 +451,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
</button>
<button
onClick={() => gradeCard("correct")}
className="flex items-center gap-2 px-6 py-3 rounded-xl border-2 border-success/30 text-success font-medium hover:bg-success-bg transition-all duration-200 cursor-pointer bg-bg-base/80 backdrop-blur"
className="flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border-2 border-success/30 bg-bg-surface px-4 font-bold text-success transition-colors hover:bg-success-bg sm:flex-none sm:px-7"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />

View file

@ -21,7 +21,7 @@ export function ImportModal({
const [activeTab, setActiveTab] = useState<"generate" | "import" | "create">("import");
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm animate-fade-in"
@ -29,14 +29,15 @@ export function ImportModal({
/>
{/* Modal */}
<div className="relative w-full max-w-2xl max-h-[90vh] bg-bg-surface rounded-2xl shadow-[var(--shadow-modal)] animate-slide-up flex flex-col">
<div className="relative flex max-h-[92vh] w-full max-w-2xl animate-slide-up flex-col rounded-t-[2rem] border border-border-light bg-bg-surface shadow-[var(--shadow-modal)] sm:rounded-[2rem]">
{/* Header */}
<div className="flex items-center justify-between px-6 pt-5 pb-0">
<h2 className="text-lg font-semibold text-text-heading">
<h2 className="editorial-title text-2xl text-text-heading">
Import {importType === "flashcards" ? "Flashcard Deck" : "Quiz"}
</h2>
<button
onClick={onClose}
aria-label="Close import dialog"
className="p-1.5 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View file

@ -50,7 +50,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
<div className="max-w-3xl mx-auto space-y-8 animate-fade-in pb-12">
{/* Top Banner */}
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-8 text-center relative overflow-hidden">
<div className="relative overflow-hidden rounded-[2rem] border border-border-light bg-bg-surface p-6 text-center shadow-[var(--shadow-card-hover)] sm:p-8">
{percentage >= 80 && (
<div className="absolute top-0 left-0 w-full h-2 bg-success"></div>
)}
@ -61,7 +61,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
<div className="absolute top-0 left-0 w-full h-2 bg-error"></div>
)}
<h2 className="text-2xl font-bold text-text-heading mb-2">Quiz Complete</h2>
<h2 className="editorial-title mb-2 text-4xl text-text-heading">Quiz complete</h2>
{attempt.isPartialRetake && (
<p className="text-sm text-text-secondary mb-4">(Partial Retake)</p>
)}
@ -71,7 +71,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
<span className="text-lg text-text-muted mb-1 font-medium">({parseFloat(attempt.score.toFixed(2))} / {attempt.maxScore})</span>
</div>
<div className="flex justify-center gap-4">
<div className="flex flex-col justify-center gap-3 sm:flex-row">
{onClose && (
<button
onClick={onClose}

View file

@ -320,10 +320,10 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
)}
{/* Main Unified Card */}
<div className="w-full max-w-3xl bg-bg-surface-alt rounded-2xl border border-border-light shadow-[var(--shadow-card)] overflow-hidden mb-8 mt-12">
<div className="mb-8 mt-4 w-full max-w-4xl overflow-hidden rounded-[2rem] border border-border-light bg-bg-surface shadow-[var(--shadow-card-hover)] md:mt-8">
{/* Progress Header */}
<div className="px-6 md:px-8 py-5 flex items-center gap-4">
<div className="flex items-center gap-4 border-b border-border-light bg-bg-surface-alt/55 px-5 py-4 md:px-8">
<div className="text-sm font-mono text-text-secondary whitespace-nowrap tracking-wider">
Q {currentIndex + 1} / {order.length}
</div>
@ -339,7 +339,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
</div>
{/* Question Area */}
<div className="px-6 md:px-8 pb-8 pt-2">
<div className="px-5 pb-6 pt-6 md:px-10 md:pb-10 md:pt-8">
{/* Tags */}
<div className="flex items-center justify-between mb-8">
@ -352,7 +352,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
</div>
{/* Prompt */}
<div className="markdown-content text-xl text-text-heading mb-8 font-serif leading-relaxed font-semibold">
<div className="markdown-content editorial-title mb-8 text-2xl leading-relaxed text-text-heading md:text-3xl">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{currentQ.prompt}
</ReactMarkdown>
@ -392,7 +392,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
<div
key={opt.id}
onClick={() => toggleOption(opt.id)}
className={`flex items-center gap-4 px-5 py-4 rounded-xl border transition-all duration-200 ${stateClass}`}
className={`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 ${
@ -478,7 +478,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
})()}
{/* Action Button */}
<div className="mt-10 flex">
<div className="sticky bottom-3 z-10 mt-10 flex rounded-2xl bg-bg-surface/90 p-1 backdrop-blur md:static md:bg-transparent md:p-0">
{!isSubmitted ? (
<button
onClick={handleSubmit}

View file

@ -24,17 +24,21 @@ export function ClassHeader({ classSlug, className }: ClassHeaderProps) {
return (
<>
<div className="mb-6">
<div className="mb-7 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<Link
href="/"
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-text-heading mb-2 transition-colors"
className="mb-3 inline-flex min-h-10 items-center gap-2 rounded-lg pr-3 text-xs font-bold uppercase tracking-[0.15em] text-text-muted transition-colors hover:text-primary"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
All Classes
Library
</Link>
<h1 className="text-2xl font-bold text-text-heading">{className}</h1>
<p className="text-xs font-bold uppercase tracking-[0.18em] text-primary">Active class</p>
<h1 className="editorial-title mt-1 text-4xl text-text-heading sm:text-5xl">{className}</h1>
</div>
<p className="max-w-sm text-sm leading-6 text-text-secondary">Choose a mode, then settle in for a focused study session.</p>
</div>
<ClassTabs classSlug={classSlug} />

View file

@ -12,8 +12,8 @@ export function ClassTabs({ classSlug }: ClassTabsProps) {
const pathname = usePathname();
return (
<div className="border-b border-border-light mb-6">
<nav className="flex gap-0" aria-label="Study modes">
<div className="mb-8 border-b border-border-light">
<nav className="flex gap-2" aria-label="Study modes">
{STUDY_MODES.map((mode) => {
const href = `/${classSlug}/${mode.path}`;
const isActive = pathname === href || pathname.startsWith(href + "/");
@ -22,7 +22,7 @@ export function ClassTabs({ classSlug }: ClassTabsProps) {
<Link
key={mode.key}
href={href}
className={`relative px-5 py-3 text-sm font-medium transition-colors duration-200 ${
className={`relative min-h-12 px-4 py-3 text-sm font-bold transition-colors duration-200 ${
isActive
? "text-primary"
: "text-text-muted hover:text-text-heading"
@ -30,7 +30,7 @@ export function ClassTabs({ classSlug }: ClassTabsProps) {
>
{mode.label}
{isActive && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full" />
<span className="absolute bottom-0 left-2 right-2 h-[3px] rounded-t-full bg-primary" />
)}
</Link>
);

View file

@ -1,11 +1,24 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import Link from "next/link";
import { ThemeToggle } from "./ThemeToggle";
function Mark() {
return (
<span className="grid h-9 w-9 place-items-center rounded-xl bg-primary text-sm font-extrabold text-white shadow-[0_8px_22px_color-mix(in_srgb,var(--theme-primary)_28%,transparent)]">
S
</span>
);
}
export function Navbar() {
const router = useRouter();
const pathname = usePathname();
const [open, setOpen] = useState(false);
const parts = pathname.split("/").filter(Boolean);
const classSlug = parts[0] && parts[0] !== "shared" ? parts[0] : null;
async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" });
@ -13,40 +26,82 @@ export function Navbar() {
router.refresh();
}
return (
<nav className="bg-bg-surface border-b border-border-light">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-14">
<Link
href="/"
className="flex items-center gap-2.5 text-text-heading font-semibold hover:text-primary transition-colors"
>
<svg
className="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
const nav = (
<>
<Link href="/" onClick={() => setOpen(false)} className="mb-9 flex items-center gap-3">
<Mark />
<span>
<span className="block text-[11px] font-bold uppercase tracking-[0.2em] text-text-muted">Your</span>
<span className="editorial-title block text-xl leading-none text-text-heading">Study Desk</span>
</span>
</Link>
<nav className="space-y-1" aria-label="Primary navigation">
<Link
href="/"
onClick={() => setOpen(false)}
className={`flex min-h-11 items-center gap-3 rounded-xl px-3 text-sm font-semibold transition-colors ${pathname === "/" ? "bg-primary text-white shadow-sm" : "text-text-secondary hover:bg-bg-surface-alt hover:text-text-heading"}`}
>
<span aria-hidden className="text-lg"></span> Library
</Link>
{classSlug && (
<div className="pt-6">
<p className="mb-2 px-3 text-[10px] font-bold uppercase tracking-[0.18em] text-text-muted">Current class</p>
<Link
href={`/${classSlug}/flashcards`}
onClick={() => setOpen(false)}
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"}`}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
Study
</Link>
<div className="flex items-center gap-4">
<ThemeToggle />
<button
onClick={handleLogout}
className="text-sm text-text-muted hover:text-text-heading transition-colors cursor-pointer"
<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"}`}
>
Sign out
</button>
<span aria-hidden></span> Quizzes
</Link>
</div>
)}
</nav>
<div className="mt-auto border-t border-border-light pt-4">
<div className="flex items-center justify-between rounded-xl px-2 py-1.5">
<span className="text-xs font-semibold text-text-muted">Appearance</span>
<ThemeToggle />
</div>
<button onClick={handleLogout} className="mt-1 flex min-h-11 w-full items-center gap-3 rounded-xl px-3 text-left text-sm font-semibold text-text-secondary transition-colors hover:bg-error-bg hover:text-error">
<span aria-hidden></span> Sign out
</button>
</div>
</nav>
</>
);
return (
<>
<aside className="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="sticky top-0 z-40 flex h-16 items-center justify-between border-b border-border-light bg-bg-surface/88 px-4 backdrop-blur-xl md:hidden">
<Link href="/" className="flex items-center gap-2.5">
<Mark />
<span className="editorial-title text-lg text-text-heading">Study Desk</span>
</Link>
<button onClick={() => setOpen(true)} className="grid h-11 w-11 place-items-center rounded-xl border border-border-light bg-bg-surface-alt text-xl text-text-heading" aria-label="Open navigation" aria-expanded={open}>
</button>
</header>
{open && (
<div className="fixed inset-0 z-50 md:hidden">
<button className="absolute inset-0 bg-black/45 backdrop-blur-sm" onClick={() => setOpen(false)} aria-label="Close navigation" />
<aside className="animate-slide-up absolute inset-y-0 right-0 flex w-[min(86vw,21rem)] flex-col border-l border-border-light bg-bg-surface p-5 shadow-[var(--shadow-modal)]">
<button onClick={() => setOpen(false)} className="absolute right-4 top-4 grid h-10 w-10 place-items-center rounded-xl text-xl text-text-muted hover:bg-bg-surface-alt" aria-label="Close navigation">×</button>
{nav}
</aside>
</div>
)}
</>
);
}

View file

@ -6,9 +6,10 @@ interface ShareMenuProps {
targetType: "DECK" | "QUIZ" | "GROUP";
contentId: string;
classSlug: string;
compact?: boolean;
}
export function ShareMenu({ targetType, contentId, classSlug }: ShareMenuProps) {
export function ShareMenu({ targetType, contentId, classSlug, compact = false }: ShareMenuProps) {
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [open, setOpen] = useState(false);
@ -45,8 +46,15 @@ export function ShareMenu({ targetType, contentId, classSlug }: ShareMenuProps)
async function handleCopy() {
if (!token) return;
const typeStr = targetType === "GROUP" ? "groups" : (targetType === "DECK" ? "flashcards" : "quizzes");
const url = `${window.location.origin}/shared/${classSlug}/${typeStr}/${token}`;
const typeStr = isGroupShared
? "groups"
: targetType === "GROUP"
? "groups"
: targetType === "DECK"
? "flashcards"
: "quizzes";
const itemQuery = isGroupShared ? `?itemId=${encodeURIComponent(contentId)}` : "";
const url = `${window.location.origin}/shared/${classSlug}/${typeStr}/${token}${itemQuery}`;
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
@ -56,7 +64,7 @@ export function ShareMenu({ targetType, contentId, classSlug }: ShareMenuProps)
<div className="relative">
<button
onClick={() => setOpen(!open)}
className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
className={`${compact ? "h-8 w-8 rounded-lg" : "h-10 w-10 rounded-xl"} grid place-items-center text-text-muted transition-colors hover:bg-bg-surface-alt hover:text-primary`}
title="Share"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -67,8 +75,8 @@ export function ShareMenu({ targetType, contentId, classSlug }: ShareMenuProps)
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute right-0 mt-2 w-72 bg-bg-surface rounded-xl shadow-[var(--shadow-modal)] border border-border-light z-50 p-4 animate-slide-up origin-top-right">
<h4 className="text-sm font-semibold text-text-heading mb-3">Public Sharing</h4>
<div className="absolute right-0 z-50 mt-2 w-72 origin-top-right animate-slide-up rounded-2xl border border-border-light bg-bg-surface p-5 shadow-[var(--shadow-modal)]">
<h4 className="editorial-title mb-3 text-xl text-text-heading">Public sharing</h4>
{loading ? (
<div className="h-8 bg-bg-surface-alt rounded w-full animate-subtle-pulse" />

View file

@ -12,7 +12,7 @@ export function ThemeToggle() {
}, []);
if (!mounted) {
return <div className="w-8 h-8" />; // placeholder to prevent layout shift
return <div className="h-10 w-10" />; // placeholder to prevent layout shift
}
function toggleTheme() {
@ -30,9 +30,9 @@ export function ThemeToggle() {
return (
<button
onClick={toggleTheme}
className="p-1.5 text-text-muted hover:text-text-heading transition-colors cursor-pointer rounded-lg hover:bg-bg-surface-alt flex items-center justify-center"
className="flex h-10 w-10 items-center justify-center rounded-xl border border-border-light bg-bg-surface-alt text-text-muted transition-colors hover:border-primary/30 hover:text-primary"
title={isDark ? "Switch to Light Mode" : "Switch to Dark Mode"}
aria-label="Toggle Dark Mode"
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
>
{isDark ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">