Initial working commit
Some checks failed
Automated Container Build / build-and-push (push) Failing after 7s

This commit is contained in:
Elijah 2026-06-27 19:19:38 -07:00
parent 666ceb7325
commit b7ce314f01
105 changed files with 35510 additions and 11 deletions

50
.gitignore vendored
View file

@ -1,13 +1,41 @@
# IDE & Editor files
.vscode/
.cursor/
.idea/
*.swp
*.xml
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Build outputs (if your Node app eventually compiles or bundles code)
dist/
build/
out/
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# Local application run data
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
AGENTS.md Normal file
View file

@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
CLAUDE.md Normal file
View file

@ -0,0 +1 @@
@AGENTS.md

29
Dockerfile Normal file
View file

@ -0,0 +1,29 @@
# ---- deps ----
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ---- builder ----
FROM node:20-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
# ---- runner ----
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN useradd --system --create-home appuser
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./
COPY docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh && chown -R appuser:appuser /app
USER appuser
EXPOSE 3000
ENTRYPOINT ["./docker-entrypoint.sh"]

36
README.md Normal file
View file

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

24
SKILL.md Normal file
View file

@ -0,0 +1,24 @@
---
name: adding-an-api-route
description: Use when adding a new Next.js API route to this project. Keeps routes thin and logic in the right layer instead of inline in the route file.
---
# Adding a new API route
1. Check `study-app-implementation-plan.md`'s Section 9 file tree first. If
this route is already named there, follow that path and method exactly
rather than inventing a different shape.
2. Create the route file under `src/app/api/<resource>/route.ts`, or
`src/app/api/<resource>/[id]/route.ts` for a single-resource endpoint.
3. The route handler does exactly three things: validate the request body
(reuse a Zod schema from `lib/validation/` if one already exists for this
shape, write one if it doesn't), call one function in the matching
`services/<resource>Service.ts`, and return a `NextResponse`. No Prisma
calls and no business logic directly in the route file.
4. If the logic doesn't fit an existing service file, add a new exported
function to the closest matching one. Only create a new
`services/<resource>Service.ts` if this is genuinely a new resource, don't
bolt unrelated logic onto an existing service to avoid creating a file.
5. Run `npm run lint` before considering the route done. A failing
`max-lines` or `filename-case` error means something is in the wrong
place, not that the rule needs an exception.

BIN
dev.db Normal file

Binary file not shown.

View file

@ -0,0 +1,12 @@
services:
study-app:
build:
target: deps
command: npm run dev
volumes:
- .:/app
- /app/node_modules
environment:
- NODE_ENV=development
ports:
- "3000:3000"

14
docker-compose.yml Normal file
View file

@ -0,0 +1,14 @@
services:
study-app:
build: .
container_name: study-app
restart: unless-stopped
ports:
- "3000:3000"
environment:
- DATABASE_URL=file:/app/data/study.db
- SESSION_SECRET=${SESSION_SECRET}
- ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH}
- NODE_ENV=production
volumes:
- ./data:/app/data

4
docker-entrypoint.sh Normal file
View file

@ -0,0 +1,4 @@
#!/bin/sh
set -e
npx prisma migrate deploy
exec node server.js

18
eslint.config.mjs Normal file
View file

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

8
next.config.ts Normal file
View file

@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
serverExternalPackages: ["better-sqlite3"],
};
export default nextConfig;

9777
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

36
package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "study-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@prisma/adapter-better-sqlite3": "^7.8.0",
"@prisma/client": "^7.8.0",
"argon2": "^0.44.0",
"better-sqlite3": "^12.11.1",
"iron-session": "^8.0.4",
"jsonrepair": "^3.14.1",
"next": "16.2.9",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.9",
"prisma": "^7.8.0",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

9
prisma.config.ts Normal file
View file

@ -0,0 +1,9 @@
import path from "node:path";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: path.join(__dirname, "prisma", "schema.prisma"),
datasource: {
url: process.env.DATABASE_URL ?? "file:./dev.db",
},
});

View file

@ -0,0 +1,130 @@
-- CreateTable
CREATE TABLE "Class" (
"id" TEXT NOT NULL PRIMARY KEY,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "Deck" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Deck_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Flashcard" (
"id" TEXT NOT NULL PRIMARY KEY,
"deckId" TEXT NOT NULL,
"front" TEXT NOT NULL,
"back" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "Flashcard_deckId_fkey" FOREIGN KEY ("deckId") REFERENCES "Deck" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "QuizSet" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "QuizSet_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Question" (
"id" TEXT NOT NULL PRIMARY KEY,
"quizSetId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"prompt" TEXT NOT NULL,
"rationale" TEXT NOT NULL,
"category" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "Question_quizSetId_fkey" FOREIGN KEY ("quizSetId") REFERENCES "QuizSet" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AnswerOption" (
"id" TEXT NOT NULL PRIMARY KEY,
"questionId" TEXT NOT NULL,
"text" TEXT NOT NULL,
"isCorrect" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "AnswerOption_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "Question" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "StudyProgress" (
"id" TEXT NOT NULL PRIMARY KEY,
"contentType" TEXT NOT NULL,
"deckId" TEXT,
"quizSetId" TEXT,
"mode" TEXT NOT NULL,
"currentIndex" INTEGER NOT NULL DEFAULT 0,
"orderJson" TEXT NOT NULL,
"answersJson" TEXT,
"cardResultsJson" TEXT,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "StudyProgress_deckId_fkey" FOREIGN KEY ("deckId") REFERENCES "Deck" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "StudyProgress_quizSetId_fkey" FOREIGN KEY ("quizSetId") REFERENCES "QuizSet" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "QuizAttempt" (
"id" TEXT NOT NULL PRIMARY KEY,
"quizSetId" TEXT NOT NULL,
"score" REAL NOT NULL,
"maxScore" INTEGER NOT NULL,
"answersJson" TEXT NOT NULL,
"isPartialRetake" BOOLEAN NOT NULL DEFAULT false,
"completedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "QuizAttempt_quizSetId_fkey" FOREIGN KEY ("quizSetId") REFERENCES "QuizSet" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ShareLink" (
"id" TEXT NOT NULL PRIMARY KEY,
"targetType" TEXT NOT NULL,
"deckId" TEXT,
"quizSetId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ShareLink_deckId_fkey" FOREIGN KEY ("deckId") REFERENCES "Deck" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ShareLink_quizSetId_fkey" FOREIGN KEY ("quizSetId") REFERENCES "QuizSet" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AuthSecurity" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT DEFAULT 1,
"failedAttempts" INTEGER NOT NULL DEFAULT 0,
"lockedUntil" DATETIME,
"lastAttemptAt" DATETIME
);
-- CreateTable
CREATE TABLE "Setting" (
"key" TEXT NOT NULL PRIMARY KEY,
"value" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "Class_slug_key" ON "Class"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "StudyProgress_deckId_mode_key" ON "StudyProgress"("deckId", "mode");
-- CreateIndex
CREATE UNIQUE INDEX "StudyProgress_quizSetId_mode_key" ON "StudyProgress"("quizSetId", "mode");
-- CreateIndex
CREATE UNIQUE INDEX "ShareLink_deckId_key" ON "ShareLink"("deckId");
-- CreateIndex
CREATE UNIQUE INDEX "ShareLink_quizSetId_key" ON "ShareLink"("quizSetId");

View file

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

135
prisma/schema.prisma Normal file
View file

@ -0,0 +1,135 @@
datasource db {
provider = "sqlite"
}
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
model Class {
id String @id @default(uuid())
slug String @unique
name String
sortOrder Int @default(0)
createdAt DateTime @default(now())
decks Deck[]
quizSets QuizSet[]
}
model Deck {
id String @id @default(uuid())
classId String
name String
description String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
cards Flashcard[]
shareLink ShareLink?
progress StudyProgress[]
}
model Flashcard {
id String @id @default(uuid())
deckId String
front String
back String
sortOrder Int @default(0)
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
}
model QuizSet {
id String @id @default(uuid())
classId String
name String
description String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
questions Question[]
attempts QuizAttempt[]
shareLink ShareLink?
progress StudyProgress[]
}
model Question {
id String @id @default(uuid())
quizSetId String
type String // "MULTIPLE_CHOICE" | "SATA"
prompt String
rationale String
category String
sortOrder Int @default(0)
quizSet QuizSet @relation(fields: [quizSetId], references: [id], onDelete: Cascade)
options AnswerOption[]
}
model AnswerOption {
id String @id @default(uuid())
questionId String
text String
isCorrect Boolean @default(false)
sortOrder Int @default(0)
question Question @relation(fields: [questionId], references: [id], onDelete: Cascade)
}
model StudyProgress {
id String @id @default(uuid())
contentType String // "DECK" | "QUIZ"
deckId String?
quizSetId String?
mode String // "SEQUENTIAL" | "SHUFFLED"
currentIndex Int @default(0)
orderJson String // JSON array of card/question ids
answersJson String? // in-progress quiz answers
cardResultsJson String? // per-card grades for decks
updatedAt DateTime @updatedAt
deck Deck? @relation(fields: [deckId], references: [id], onDelete: Cascade)
quizSet QuizSet? @relation(fields: [quizSetId], references: [id], onDelete: Cascade)
@@unique([deckId, mode])
@@unique([quizSetId, mode])
}
model QuizAttempt {
id String @id @default(uuid())
quizSetId String
score Float
maxScore Int
answersJson String
isPartialRetake Boolean @default(false)
completedAt DateTime @default(now())
quizSet QuizSet @relation(fields: [quizSetId], references: [id], onDelete: Cascade)
}
model ShareLink {
id String @id @default(uuid())
targetType String // "DECK" | "QUIZ"
deckId String? @unique
quizSetId String? @unique
createdAt DateTime @default(now())
deck Deck? @relation(fields: [deckId], references: [id], onDelete: Cascade)
quizSet QuizSet? @relation(fields: [quizSetId], references: [id], onDelete: Cascade)
}
model AuthSecurity {
id Int @id @default(1)
failedAttempts Int @default(0)
lockedUntil DateTime?
lastAttemptAt DateTime?
}
model Setting {
key String @id
value String
}

1
public/file.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1 KiB

1
public/next.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View file

@ -0,0 +1,173 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import { FlashcardViewer } from "@/components/flashcards/FlashcardViewer";
import { CardManager } from "@/components/flashcards/CardManager";
import { ShareMenu } from "@/components/ui/ShareMenu";
interface Card {
id: string;
front: string;
back: string;
sortOrder: number;
}
interface DeckData {
id: string;
name: string;
description: string | null;
cards: Card[];
class: { slug: string; name: string };
progress: Array<{
mode: "SEQUENTIAL" | "SHUFFLED";
currentIndex: number;
orderJson: string;
cardResultsJson: string | null;
updatedAt: string;
}>;
}
export default function DeckStudyPage() {
const params = useParams();
const router = useRouter();
const classSlug = params.classSlug as string;
const deckId = params.deckId as string;
const [deck, setDeck] = useState<DeckData | null>(null);
const [loading, setLoading] = useState(true);
const [view, setView] = useState<"study" | "manage">("study");
const [restartKey, setRestartKey] = useState(0);
async function handleRestart() {
if (!deck) return;
// Clear progress in database for both modes
await Promise.all([
fetch("/api/progress", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SEQUENTIAL" })
}).catch(() => {}),
fetch("/api/progress", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SHUFFLED" })
}).catch(() => {})
]);
// Clear locally and force remount
setDeck({ ...deck, progress: [] });
setRestartKey(k => k + 1);
}
const fetchDeck = useCallback(async () => {
try {
const res = await fetch(`/api/decks/${deckId}`);
if (!res.ok) throw new Error("Not found");
const data = await res.json();
setDeck(data);
} catch {
router.push(`/${classSlug}/flashcards`);
} finally {
setLoading(false);
}
}, [deckId, classSlug, router]);
useEffect(() => {
fetchDeck();
}, [fetchDeck]);
if (loading || !deck) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="animate-subtle-pulse">
<div className="h-8 bg-bg-surface-alt rounded w-1/3 mb-4" />
<div className="h-64 bg-bg-surface rounded-xl border border-border-light" />
</div>
</div>
);
}
return (
<div className="max-w-4xl mx-auto w-full py-2 md:py-4">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<button
onClick={() => router.push(`/${classSlug}/flashcards`)}
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-text-heading mb-2 transition-colors 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="M15 19l-7-7 7-7" />
</svg>
Back to decks
</button>
<h1 className="text-xl font-bold text-text-heading">{deck.name}</h1>
{deck.description && (
<p className="text-sm text-text-secondary mt-1">{deck.description}</p>
)}
</div>
{/* View toggle & Share */}
<div className="flex items-center gap-2 md:gap-4">
{view === "study" && (
<button
onClick={handleRestart}
className="p-1.5 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
title="Restart Session"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
)}
<ShareMenu targetType="DECK" contentId={deck.id} classSlug={classSlug} />
<div className="flex bg-bg-surface-alt rounded-lg p-0.5 border border-border-light">
<button
onClick={() => setView("study")}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${
view === "study"
? "bg-bg-surface text-text-heading shadow-sm"
: "text-text-muted hover:text-text-heading"
}`}
>
Study
</button>
<button
onClick={() => setView("manage")}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${
view === "manage"
? "bg-bg-surface text-text-heading shadow-sm"
: "text-text-muted hover:text-text-heading"
}`}
>
Manage
</button>
</div>
</div>
</div>
{/* Content */}
{view === "study" ? (
<FlashcardViewer
key={restartKey}
cards={deck.cards}
deckId={deck.id}
initialProgress={
deck.progress?.length
? deck.progress.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0]
: null
}
/>
) : (
<CardManager
cards={deck.cards}
deckId={deck.id}
onCardsChanged={fetchDeck}
/>
)}
</div>
);
}

View file

@ -0,0 +1,253 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ImportModal } from "@/components/import/ImportModal";
interface DeckItem {
id: string;
name: string;
description: string | null;
_count: { cards: number };
progress: Array<{
mode: string;
currentIndex: number;
orderJson: string;
cardResultsJson: string | null;
}>;
}
export default function FlashcardsPage() {
const params = useParams();
const classSlug = params.classSlug as string;
const [decks, setDecks] = useState<DeckItem[]>([]);
const [loading, setLoading] = useState(true);
const [showImport, setShowImport] = useState(false);
const [classId, setClassId] = useState<string>("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const fetchDecks = useCallback(async (cId: string) => {
try {
const res = await fetch(`/api/decks/list?classId=${cId}`);
if (res.ok) {
const data = await res.json();
setDecks(Array.isArray(data) ? data : []);
}
} catch {
setDecks([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
async function init() {
try {
const classRes = await fetch("/api/classes");
const classes = await classRes.json();
const cls = classes.find((c: { slug: string }) => c.slug === classSlug);
if (!cls) {
setLoading(false);
return;
}
setClassId(cls.id);
fetchDecks(cls.id);
} catch {
setLoading(false);
}
}
init();
}, [classSlug, fetchDecks]);
async function handleDelete(id: string, name: string) {
if (!confirm(`Delete "${name}" and all its cards?`)) return;
await fetch(`/api/decks/${id}`, { method: "DELETE" });
setDecks((prev) => prev.filter((d) => d.id !== id));
}
async function handleRename(id: string) {
if (!editName.trim()) return;
await fetch(`/api/decks/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editName.trim() }),
});
setDecks((prev) =>
prev.map((d) => (d.id === id ? { ...d, name: editName.trim() } : d))
);
setEditingId(null);
}
function getProgressLabel(deck: DeckItem) {
if (!deck.progress?.length) return null;
const prog = deck.progress[0];
const order = JSON.parse(prog.orderJson) as string[];
const results = prog.cardResultsJson
? (JSON.parse(prog.cardResultsJson) as Record<string, string>)
: {};
const correctCount = Object.values(results).filter((r) => r === "correct").length;
const total = order.length;
const current = Math.min(prog.currentIndex + 1, total);
const modeLabel = prog.mode === "SHUFFLED" ? "shuffled" : "sequential";
return `${current}/${total}, ${modeLabel} · ${correctCount}`;
}
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-text-heading">Flashcard Decks</h2>
<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"
>
<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>
Import Deck
</button>
</div>
{/* Loading */}
{loading && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1, 2].map((i) => (
<div key={i} className="bg-bg-surface rounded-xl border border-border-light p-6 animate-subtle-pulse">
<div className="h-5 bg-bg-surface-alt rounded w-2/3 mb-3" />
<div className="h-4 bg-bg-surface-alt rounded w-1/3" />
</div>
))}
</div>
)}
{/* Empty state */}
{!loading && decks.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>
<h3 className="text-lg font-semibold text-text-heading mb-1">No flashcard decks yet</h3>
<p className="text-text-secondary mb-4">Import your first deck to start studying</p>
<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 cursor-pointer"
>
Import Deck
</button>
</div>
)}
{/* Deck grid */}
{!loading && decks.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{decks.map((deck) => {
const progressLabel = getProgressLabel(deck);
return (
<div
key={deck.id}
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-6">
{/* Name (editable) */}
{editingId === deck.id ? (
<div className="flex gap-2 mb-3">
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleRename(deck.id)}
autoFocus
className="flex-1 px-3 py-1.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<button
onClick={() => handleRename(deck.id)}
className="text-sm text-primary hover:text-primary-hover cursor-pointer"
>
Save
</button>
<button
onClick={() => setEditingId(null)}
className="text-sm text-text-muted hover:text-text-heading cursor-pointer"
>
Cancel
</button>
</div>
) : (
<h3 className="text-lg font-semibold text-text-heading mb-1">
{deck.name}
</h3>
)}
{deck.description && (
<p className="text-sm text-text-secondary mb-3 line-clamp-2">{deck.description}</p>
)}
{/* Stats */}
<div className="flex items-center gap-3">
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-badge-bg text-badge-text text-xs font-medium">
{deck._count.cards} {deck._count.cards === 1 ? "card" : "cards"}
</span>
{progressLabel && (
<span className="text-xs text-text-muted">{progressLabel}</span>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-border-light">
<Link
href={`/${classSlug}/flashcards/${deck.id}`}
className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200"
>
{deck.progress?.length ? "Continue" : "Study"}
</Link>
<button
onClick={() => {
setEditingId(deck.id);
setEditName(deck.name);
}}
className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
title="Rename"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDelete(deck.id, deck.name)}
className="p-2 rounded-lg text-text-muted hover:text-error hover:bg-error-bg transition-all duration-200 cursor-pointer"
title="Delete"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
);
})}
</div>
)}
{/* Import Modal */}
{showImport && (
<ImportModal
classId={classId}
importType="flashcards"
onClose={() => setShowImport(false)}
onImported={() => {
setShowImport(false);
setLoading(true);
// Re-fetch
window.location.reload();
}}
/>
)}
</div>
);
}

View file

@ -0,0 +1,22 @@
import { getClassBySlug } from "@/services/classService";
import { notFound } from "next/navigation";
import { ClassHeader } from "@/components/ui/ClassHeader";
export default async function ClassLayout(props: LayoutProps<"/[classSlug]">) {
const { classSlug } = await props.params;
const classData = await getClassBySlug(classSlug);
if (!classData) {
notFound();
}
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Dynamic Header & Tabs */}
<ClassHeader classSlug={classSlug} className={classData.name} />
{/* Page content */}
{props.children}
</div>
);
}

View file

@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export default async function ClassPage(props: PageProps<"/[classSlug]">) {
const { classSlug } = await props.params;
redirect(`/${classSlug}/flashcards`);
}

View file

@ -0,0 +1,177 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import { QuizViewer } from "@/components/quizzes/QuizViewer";
import { AttemptHistory } from "@/components/quizzes/AttemptHistory";
import { ShareMenu } from "@/components/ui/ShareMenu";
interface Option {
id: string;
text: string;
isCorrect: boolean;
}
interface Question {
id: string;
type: string;
prompt: string;
rationale: string;
category: string;
options: Option[];
}
interface QuizAttempt {
id: string;
score: number;
maxScore: number;
answersJson: string;
isPartialRetake: boolean;
completedAt: string;
}
interface QuizData {
id: string;
name: string;
description: string | null;
questions: Question[];
class: { slug: string; name: string };
progress: Array<{
mode: string;
currentIndex: number;
orderJson: string;
answersJson: string | null;
}>;
}
export default function QuizStudyPage() {
const params = useParams();
const router = useRouter();
const classSlug = params.classSlug as string;
const quizId = params.quizId as string;
const [quiz, setQuiz] = useState<QuizData | null>(null);
const [attempts, setAttempts] = useState<QuizAttempt[]>([]);
const [loading, setLoading] = useState(true);
const [view, setView] = useState<"history" | "take">("take");
const [retakeIds, setRetakeIds] = useState<string[] | null>(null);
const fetchQuizAndAttempts = useCallback(async () => {
try {
const [quizRes, attemptsRes] = await Promise.all([
fetch(`/api/quizzes/${quizId}`),
fetch(`/api/quizzes/${quizId}/attempt`)
]);
if (!quizRes.ok) throw new Error("Not found");
const [quizData, attemptsData] = await Promise.all([
quizRes.json(),
attemptsRes.ok ? attemptsRes.json() : []
]);
setQuiz(quizData);
setAttempts(attemptsData);
// Optional: keep track of attempt count, but default view is already "take"
if (attemptsData.length > 0) {
// We can do something here if we wanted to auto-switch to history, but user wants take
}
} catch {
router.push(`/${classSlug}/quizzes`);
} finally {
setLoading(false);
}
}, [quizId, classSlug, router]);
useEffect(() => {
fetchQuizAndAttempts();
}, [fetchQuizAndAttempts]);
if (loading || !quiz) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="animate-subtle-pulse">
<div className="h-8 bg-bg-surface-alt rounded w-1/3 mb-4" />
<div className="h-64 bg-bg-surface rounded-xl border border-border-light" />
</div>
</div>
);
}
return (
<div className="max-w-4xl mx-auto w-full py-2 md:py-4">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<button
onClick={() => router.push(`/${classSlug}/quizzes`)}
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-text-heading mb-2 transition-colors 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="M15 19l-7-7 7-7" />
</svg>
Back to quizzes
</button>
<h1 className="text-xl font-bold text-text-heading">{quiz.name}</h1>
{quiz.description && (
<p className="text-sm text-text-secondary mt-1">{quiz.description}</p>
)}
</div>
{/* View toggle & Share */}
<div className="flex items-center gap-4">
<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">
<button
onClick={() => setView("history")}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${
view === "history"
? "bg-bg-surface text-text-heading shadow-sm"
: "text-text-muted hover:text-text-heading"
}`}
>
History
</button>
<button
onClick={() => setView("take")}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${
view === "take"
? "bg-bg-surface text-text-heading shadow-sm"
: "text-text-muted hover:text-text-heading"
}`}
>
Take Quiz
</button>
</div>
)}
</div>
</div>
{/* Content */}
{view === "history" && attempts.length > 0 ? (
<AttemptHistory
quiz={quiz}
attempts={attempts}
onRetake={(missedIds) => {
setRetakeIds(missedIds);
setView("take");
}}
/>
) : (
<QuizViewer
quiz={quiz}
retakeIds={retakeIds}
onFinished={() => {
setRetakeIds(null);
fetchQuizAndAttempts();
setView("history");
}}
/>
)}
</div>
);
}

View file

@ -0,0 +1,174 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ImportModal } from "@/components/import/ImportModal";
interface QuizItem {
id: string;
name: string;
description: string | null;
_count: { questions: number; attempts: number };
}
export default function QuizzesPage() {
const params = useParams();
const classSlug = params.classSlug as string;
const [quizzes, setQuizzes] = useState<QuizItem[]>([]);
const [loading, setLoading] = useState(true);
const [showImport, setShowImport] = useState(false);
const [classId, setClassId] = useState<string>("");
const fetchQuizzes = useCallback(async (cId: string) => {
try {
const res = await fetch(`/api/quizzes/list?classId=${cId}`);
if (res.ok) {
const data = await res.json();
setQuizzes(Array.isArray(data) ? data : []);
}
} catch {
setQuizzes([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
async function init() {
try {
const classRes = await fetch("/api/classes");
const classes = await classRes.json();
const cls = classes.find((c: { slug: string }) => c.slug === classSlug);
if (!cls) {
setLoading(false);
return;
}
setClassId(cls.id);
fetchQuizzes(cls.id);
} catch {
setLoading(false);
}
}
init();
}, [classSlug, fetchQuizzes]);
async function handleDelete(id: string, name: string) {
if (!confirm(`Delete "${name}" and all its questions and attempts?`)) return;
await fetch(`/api/quizzes/${id}`, { method: "DELETE" });
setQuizzes((prev) => prev.filter((q) => q.id !== id));
}
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-text-heading">Quizzes</h2>
<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"
>
<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>
Import Quiz
</button>
</div>
{/* Loading */}
{loading && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1, 2].map((i) => (
<div key={i} className="bg-bg-surface rounded-xl border border-border-light p-6 animate-subtle-pulse">
<div className="h-5 bg-bg-surface-alt rounded w-2/3 mb-3" />
<div className="h-4 bg-bg-surface-alt rounded w-1/3" />
</div>
))}
</div>
)}
{/* Empty state */}
{!loading && quizzes.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="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>
</div>
<h3 className="text-lg font-semibold text-text-heading mb-1">No quizzes yet</h3>
<p className="text-text-secondary mb-4">Import your first quiz to start practicing</p>
<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 cursor-pointer"
>
Import Quiz
</button>
</div>
)}
{/* Quiz grid */}
{!loading && quizzes.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{quizzes.map((quiz) => (
<div
key={quiz.id}
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-6">
<h3 className="text-lg font-semibold text-text-heading mb-1">
{quiz.name}
</h3>
{quiz.description && (
<p className="text-sm text-text-secondary mb-3 line-clamp-2">
{quiz.description}
</p>
)}
<div className="flex items-center gap-3">
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-badge-bg text-badge-text text-xs font-medium">
{quiz._count.questions} {quiz._count.questions === 1 ? "question" : "questions"}
</span>
{quiz._count.attempts > 0 && (
<span className="text-xs text-text-muted">
{quiz._count.attempts} {quiz._count.attempts === 1 ? "attempt" : "attempts"}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-border-light">
<Link
href={`/${classSlug}/quizzes/${quiz.id}`}
className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200"
>
Start Quiz
</Link>
<button
onClick={() => handleDelete(quiz.id, quiz.name)}
className="p-2 rounded-lg text-text-muted hover:text-error hover:bg-error-bg transition-all duration-200 cursor-pointer"
title="Delete"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Import Modal */}
{showImport && (
<ImportModal
classId={classId}
importType="quizzes"
onClose={() => setShowImport(false)}
onImported={() => {
setShowImport(false);
window.location.reload();
}}
/>
)}
</div>
);
}

View file

@ -0,0 +1,21 @@
import { isAuthenticated } from "@/lib/auth";
import { redirect } from "next/navigation";
import { Navbar } from "@/components/ui/Navbar";
export default async function ProtectedLayout({
children,
}: {
children: React.ReactNode;
}) {
const authed = await isAuthenticated();
if (!authed) {
redirect("/login");
}
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1">{children}</main>
</div>
);
}

View file

@ -0,0 +1,208 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
interface ClassItem {
id: string;
slug: string;
name: string;
_count: { decks: number; quizSets: number };
}
export default function HomePage() {
const [classes, setClasses] = useState<ClassItem[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false);
useEffect(() => {
fetchClasses();
}, []);
async function fetchClasses() {
try {
const res = await fetch("/api/classes");
const data = await res.json();
setClasses(data);
} 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);
}
}
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();
}
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>
<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>
))}
</div>
)}
{/* Empty state */}
{!loading && classes.length === 0 && (
<div className="text-center py-16">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-bg-callout mb-4">
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h2 className="text-lg font-semibold text-text-heading mb-1">
No classes yet
</h2>
<p className="text-text-secondary mb-4">
Create your first class to start studying
</p>
<button
onClick={() => setShowCreate(true)}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Your First Class
</button>
</div>
)}
{/* 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"
>
<h3 className="text-lg font-semibold text-text-heading group-hover:text-primary transition-colors">
{cls.name}
</h3>
<div className="flex gap-4 mt-3">
<span className="inline-flex items-center gap-1.5 text-sm text-text-secondary">
<svg className="w-4 h-4 text-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
{cls._count.decks} {cls._count.decks === 1 ? "deck" : "decks"}
</span>
<span className="inline-flex items-center gap-1.5 text-sm text-text-secondary">
<svg className="w-4 h-4 text-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{cls._count.quizSets} {cls._count.quizSets === 1 ? "quiz" : "quizzes"}
</span>
</div>
</Link>
{/* Delete button */}
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(cls.id, cls.name);
}}
className="absolute top-3 right-3 p-1.5 rounded-lg text-text-muted hover:text-error hover:bg-error-bg opacity-0 group-hover:opacity-100 transition-all duration-200 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>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { createSession } from "@/lib/auth";
import { checkRateLimit } from "@/lib/rateLimiter";
// Progressive lockout thresholds from Section 4
function getLockoutDuration(failedAttempts: number): number {
if (failedAttempts >= 20) return 24 * 60 * 60 * 1000; // 24 hours
if (failedAttempts >= 15) return 30 * 60 * 1000; // 30 minutes
if (failedAttempts >= 10) return 5 * 60 * 1000; // 5 minutes
if (failedAttempts >= 5) return 1 * 60 * 1000; // 1 minute
return 0;
}
export async function POST(request: NextRequest) {
// Rate limit by IP
const forwarded = request.headers.get("x-forwarded-for");
const ip = forwarded?.split(",")[0]?.trim() ?? "unknown";
const rateCheck = checkRateLimit(ip);
if (!rateCheck.allowed) {
return NextResponse.json(
{ error: "Too many requests. Try again shortly." },
{ status: 429 }
);
}
const body = await request.json().catch(() => null);
if (!body?.password || typeof body.password !== "string") {
return NextResponse.json(
{ error: "Password is required" },
{ status: 400 }
);
}
// Check lockout state
let security = await prisma.authSecurity.findUnique({ where: { id: 1 } });
if (!security) {
security = await prisma.authSecurity.create({ data: { id: 1 } });
}
if (security.lockedUntil && security.lockedUntil > new Date()) {
const remainingMs =
security.lockedUntil.getTime() - Date.now();
const remainingMin = Math.ceil(remainingMs / 60000);
return NextResponse.json(
{
error: `Account locked. Try again in ${remainingMin} minute${remainingMin !== 1 ? "s" : ""}.`,
},
{ status: 423 }
);
}
// Verify password
const passwordHash = process.env.ADMIN_PASSWORD_HASH;
// For development: if no hash is set, accept any non-empty password
let isValid = false;
if (!passwordHash) {
isValid = body.password.length > 0;
} else {
// Dynamic import to avoid issues when argon2 isn't installed
try {
const argon2 = await import("argon2");
isValid = await argon2.verify(passwordHash, body.password);
} catch {
// Fallback: direct comparison (only for development)
isValid = body.password === passwordHash;
}
}
if (!isValid) {
const newFailedAttempts = security.failedAttempts + 1;
const lockoutMs = getLockoutDuration(newFailedAttempts);
const lockedUntil = lockoutMs > 0 ? new Date(Date.now() + lockoutMs) : null;
await prisma.authSecurity.update({
where: { id: 1 },
data: {
failedAttempts: newFailedAttempts,
lockedUntil,
lastAttemptAt: new Date(),
},
});
return NextResponse.json(
{ error: "Invalid password" },
{ status: 401 }
);
}
// Success — reset lockout, create session
await prisma.authSecurity.update({
where: { id: 1 },
data: {
failedAttempts: 0,
lockedUntil: null,
lastAttemptAt: new Date(),
},
});
await createSession();
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { destroySession } from "@/lib/auth";
export async function POST() {
await destroySession();
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import * as cardService from "@/services/cardService";
export async function PATCH(
request: NextRequest,
ctx: RouteContext<"/api/cards/[id]">
) {
const { id } = await ctx.params;
const body = await request.json().catch(() => null);
try {
const updated = await cardService.updateCard(id, {
front: body?.front?.trim(),
back: body?.back?.trim(),
});
return NextResponse.json(updated);
} catch {
return NextResponse.json({ error: "Card not found" }, { status: 404 });
}
}
export async function DELETE(
_request: NextRequest,
ctx: RouteContext<"/api/cards/[id]">
) {
const { id } = await ctx.params;
try {
await cardService.deleteCard(id);
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: "Card not found" }, { status: 404 });
}
}

View file

@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import * as classService from "@/services/classService";
export async function PATCH(
request: NextRequest,
ctx: RouteContext<"/api/classes/[id]">
) {
const { id } = await ctx.params;
const body = await request.json().catch(() => null);
if (!body || (!body.name && body.name !== "")) {
return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
}
try {
const updated = await classService.updateClass(id, {
name: body.name?.trim(),
});
return NextResponse.json(updated);
} catch {
return NextResponse.json({ error: "Class not found" }, { status: 404 });
}
}
export async function DELETE(
_request: NextRequest,
ctx: RouteContext<"/api/classes/[id]">
) {
const { id } = await ctx.params;
try {
await classService.deleteClass(id);
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: "Class not found" }, { status: 404 });
}
}

View file

@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import * as classService from "@/services/classService";
export async function GET() {
const classes = await classService.listClasses();
return NextResponse.json(classes);
}
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => null);
if (!body?.name || typeof body.name !== "string" || !body.name.trim()) {
return NextResponse.json(
{ error: "Class name is required" },
{ status: 400 }
);
}
const newClass = await classService.createClass(body.name.trim());
return NextResponse.json(newClass, { status: 201 });
}

View file

@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import * as cardService from "@/services/cardService";
export async function POST(
request: NextRequest,
ctx: RouteContext<"/api/decks/[id]/cards">
) {
const { id: deckId } = await ctx.params;
const body = await request.json().catch(() => null);
if (
!body?.front ||
!body?.back ||
typeof body.front !== "string" ||
typeof body.back !== "string"
) {
return NextResponse.json(
{ error: "front and back are required" },
{ status: 400 }
);
}
const card = await cardService.createCard(deckId, {
front: body.front.trim(),
back: body.back.trim(),
});
return NextResponse.json(card, { status: 201 });
}

View file

@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import * as deckService from "@/services/deckService";
export async function GET(
_request: NextRequest,
ctx: RouteContext<"/api/decks/[id]">
) {
const { id } = await ctx.params;
const deck = await deckService.getDeckWithCards(id);
if (!deck) {
return NextResponse.json({ error: "Deck not found" }, { status: 404 });
}
return NextResponse.json(deck);
}
export async function PATCH(
request: NextRequest,
ctx: RouteContext<"/api/decks/[id]">
) {
const { id } = await ctx.params;
const body = await request.json().catch(() => null);
try {
const updated = await deckService.updateDeck(id, {
name: body?.name?.trim(),
description: body?.description,
});
return NextResponse.json(updated);
} catch {
return NextResponse.json({ error: "Deck not found" }, { status: 404 });
}
}
export async function DELETE(
_request: NextRequest,
ctx: RouteContext<"/api/decks/[id]">
) {
const { id } = await ctx.params;
try {
await deckService.deleteDeck(id);
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: "Deck not found" }, { status: 404 });
}
}

View file

@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import * as deckService from "@/services/deckService";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const classId = searchParams.get("classId");
if (!classId) {
return NextResponse.json({ error: "classId is required" }, { status: 400 });
}
const decks = await deckService.listDecksByClass(classId);
return NextResponse.json(decks);
}

View file

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import * as deckService from "@/services/deckService";
import { flashcardImportSchema } from "@/lib/validation/importSchemas";
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => null);
if (!body) {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const { classId, data, name } = body;
if (!classId || typeof classId !== "string") {
return NextResponse.json({ error: "classId is required" }, { status: 400 });
}
// Server-side validation — never trust client
const parsed = flashcardImportSchema.safeParse(data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", details: parsed.error.issues },
{ status: 400 }
);
}
const deck = await deckService.createDeckFromImport(
classId,
parsed.data,
name?.trim() || undefined
);
return NextResponse.json(deck, { status: 201 });
}

View file

@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import * as progressService from "@/services/progressService";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const contentType = searchParams.get("contentType") as "DECK" | "QUIZ";
const contentId = searchParams.get("contentId");
const mode = searchParams.get("mode") as "SEQUENTIAL" | "SHUFFLED";
if (!contentType || !contentId || !mode) {
return NextResponse.json(
{ error: "contentType, contentId, and mode are required" },
{ status: 400 }
);
}
const progress = await progressService.getProgress(
contentType,
contentId,
mode
);
return NextResponse.json(progress);
}
export async function PATCH(request: NextRequest) {
const body = await request.json().catch(() => null);
if (!body?.contentType || !body?.contentId || !body?.mode) {
return NextResponse.json(
{ error: "contentType, contentId, and mode are required" },
{ status: 400 }
);
}
const progress = await progressService.upsertProgress({
contentType: body.contentType,
contentId: body.contentId,
mode: body.mode,
currentIndex: body.currentIndex ?? 0,
orderJson: body.orderJson,
answersJson: body.answersJson,
cardResultsJson: body.cardResultsJson,
});
return NextResponse.json(progress);
}
export async function DELETE(request: NextRequest) {
const body = await request.json().catch(() => null);
if (!body?.contentType || !body?.contentId || !body?.mode) {
return NextResponse.json(
{ error: "contentType, contentId, and mode are required" },
{ status: 400 }
);
}
await progressService.clearProgress(
body.contentType,
body.contentId,
body.mode
);
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from "next/server";
import { getQuizSetWithQuestions, createQuizAttempt, listQuizAttempts } from "@/services/quizService";
import { scoreQuiz } from "@/lib/scoring";
export async function POST(
request: NextRequest,
ctx: RouteContext<"/api/quizzes/[id]/attempt">
) {
const { id } = await ctx.params;
const body = await request.json().catch(() => null);
if (!body || !body.answersJson) {
return NextResponse.json(
{ error: "answersJson is required" },
{ status: 400 }
);
}
const quizSet = await getQuizSetWithQuestions(id);
if (!quizSet) {
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
}
// Parse answers
let answers: Record<string, string[]>;
try {
answers = JSON.parse(body.answersJson);
} catch {
return NextResponse.json({ error: "Invalid answers JSON" }, { status: 400 });
}
// Determine which questions were included in this attempt
const isPartialRetake = body.isPartialRetake === true;
// If partial retake, we only score the questions that actually had answers provided
// or that were explicitly passed in a questionIds array.
// For simplicity, we filter the quizSet questions down to what's in the answers object
// if it's a partial retake, though the client will only show those anyway.
let questionsToScore = quizSet.questions;
if (isPartialRetake) {
const answeredIds = Object.keys(answers);
questionsToScore = quizSet.questions.filter(q => answeredIds.includes(q.id));
}
// Format questions for the scoring utility
const formattedQuestions = questionsToScore.map((q) => ({
id: q.id,
type: q.type as "MULTIPLE_CHOICE" | "SATA",
options: q.options.map((o) => ({ id: o.id, isCorrect: o.isCorrect })),
}));
const { total, maxScore } = scoreQuiz(formattedQuestions, answers);
const attempt = await createQuizAttempt({
quizSetId: id,
score: total,
maxScore: maxScore,
answersJson: body.answersJson,
isPartialRetake,
});
return NextResponse.json(attempt, { status: 201 });
}
export async function GET(
_request: NextRequest,
ctx: RouteContext<"/api/quizzes/[id]/attempt">
) {
const { id } = await ctx.params;
const attempts = await listQuizAttempts(id);
return NextResponse.json(attempts);
}

View file

@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import * as quizService from "@/services/quizService";
export async function GET(
_request: NextRequest,
ctx: RouteContext<"/api/quizzes/[id]">
) {
const { id } = await ctx.params;
const quizSet = await quizService.getQuizSetWithQuestions(id);
if (!quizSet) {
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
}
return NextResponse.json(quizSet);
}
export async function PATCH(
request: NextRequest,
ctx: RouteContext<"/api/quizzes/[id]">
) {
const { id } = await ctx.params;
const body = await request.json().catch(() => null);
try {
const updated = await quizService.updateQuizSet(id, {
name: body?.name?.trim(),
description: body?.description,
});
return NextResponse.json(updated);
} catch {
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
}
}
export async function DELETE(
_request: NextRequest,
ctx: RouteContext<"/api/quizzes/[id]">
) {
const { id } = await ctx.params;
try {
await quizService.deleteQuizSet(id);
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
}
}

View file

@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import * as quizService from "@/services/quizService";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const classId = searchParams.get("classId");
if (!classId) {
return NextResponse.json({ error: "classId is required" }, { status: 400 });
}
const quizSets = await quizService.listQuizSetsByClass(classId);
return NextResponse.json(quizSets);
}

View file

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import * as quizService from "@/services/quizService";
import { quizImportSchema } from "@/lib/validation/importSchemas";
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => null);
if (!body) {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const { classId, data, name } = body;
if (!classId || typeof classId !== "string") {
return NextResponse.json({ error: "classId is required" }, { status: 400 });
}
// Server-side validation
const parsed = quizImportSchema.safeParse(data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", details: parsed.error.issues },
{ status: 400 }
);
}
const quizSet = await quizService.createQuizSetFromImport(
classId,
parsed.data,
name?.trim() || undefined
);
return NextResponse.json(quizSet, { status: 201 });
}

View file

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import * as settingsService from "@/services/settingsService";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
const instructions = await settingsService.getLlmInstructions(type);
return NextResponse.json({ value: instructions });
}
export async function PATCH(request: NextRequest) {
const { searchParams } = new URL(request.url);
const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
const body = await request.json().catch(() => null);
if (!body || typeof body.value !== "string") {
return NextResponse.json(
{ error: "value is required" },
{ status: 400 }
);
}
if (body.value === "__RESET__") {
await settingsService.resetLlmInstructions(type);
const instructions = await settingsService.getLlmInstructions(type);
return NextResponse.json({ value: instructions });
}
await settingsService.updateLlmInstructions(type, body.value);
return NextResponse.json({ value: body.value });
}

View file

@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { toggleShareLink, getShareLinkForContent } from "@/services/shareService";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const targetType = searchParams.get("targetType") as "DECK" | "QUIZ";
const contentId = searchParams.get("contentId");
if (!targetType || !contentId) {
return NextResponse.json(
{ error: "targetType and contentId are required" },
{ status: 400 }
);
}
const link = await getShareLinkForContent(targetType, contentId);
return NextResponse.json({ token: link?.id || null });
}
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => null);
if (!body?.targetType || !body?.contentId) {
return NextResponse.json(
{ error: "targetType and contentId are required" },
{ status: 400 }
);
}
const link = await toggleShareLink(body.targetType, body.contentId);
return NextResponse.json({ token: link?.id || null });
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

141
src/app/globals.css Normal file
View file

@ -0,0 +1,141 @@
@import "tailwindcss";
@theme inline {
/* ── Sage / Teal palette from reference image ── */
--color-bg-base: #f0f4f2;
--color-bg-surface: #ffffff;
--color-bg-surface-alt: #f4f7f5;
--color-bg-callout: #e8f4f0;
--color-primary: #2a7d7d;
--color-primary-hover: #1f6363;
--color-primary-light: #3a9e9e;
--color-text-heading: #1a2f2f;
--color-text-body: #2d4a4a;
--color-text-secondary: #4a6a6a;
--color-text-muted: #7a9a9a;
--color-border: #c8d8d0;
--color-border-light: #dce8e2;
--color-success: #2d8a4e;
--color-success-bg: #e8f5ed;
--color-error: #c44c4c;
--color-error-bg: #fce8e8;
--color-badge-bg: #e0eeea;
--color-badge-text: #2a6a5a;
--color-danger: #dc3545;
--color-danger-hover: #c82333;
/* Typography */
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
/* Shadows */
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
--shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.15), 0 8px 20px rgba(0, 0, 0, 0.1);
/* Animations */
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 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;
}
/* ── 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);
}
/* ── Card flip animation ── */
.perspective-1000 {
perspective: 1000px;
}
.preserve-3d {
transform-style: preserve-3d;
}
.backface-hidden {
backface-visibility: hidden;
}
.rotate-y-180 {
transform: rotateY(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; padding-left: 1.5rem; margin-bottom: 0.75rem; }
.markdown-content ol { list-style: decimal; padding-left: 1.5rem; margin-bottom: 0.75rem; }
.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 *:last-child { margin-bottom: 0; }

28
src/app/layout.tsx Normal file
View file

@ -0,0 +1,28 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
display: "swap",
});
export const metadata: Metadata = {
title: "Study App",
description: "Self-hosted study application with flashcards and quizzes",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className={`${inter.variable} h-full`} suppressHydrationWarning>
<body className="min-h-full flex flex-col font-sans antialiased">
{children}
</body>
</html>
);
}

143
src/app/login/page.tsx Normal file
View file

@ -0,0 +1,143 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || "Login failed");
return;
}
router.push("/");
router.refresh();
} catch {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-sm">
{/* 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">Study App</h1>
<p className="text-text-muted mt-1 text-sm">
Enter your password to continue
</p>
</div>
{/* Login card */}
<div className="bg-bg-surface rounded-xl shadow-[var(--shadow-card)] border border-border-light p-6">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-text-secondary mb-1.5"
>
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
autoFocus
className="w-full px-3.5 py-2.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200"
/>
</div>
{error && (
<div className="flex items-center gap-2 text-sm text-error bg-error-bg rounded-lg px-3 py-2.5">
<svg
className="w-4 h-4 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{error}
</div>
)}
<button
type="submit"
disabled={loading || !password}
className="w-full py-2.5 px-4 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 cursor-pointer"
>
{loading ? (
<span className="inline-flex items-center gap-2">
<svg
className="animate-spin h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Signing in...
</span>
) : (
"Sign In"
)}
</button>
</form>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,101 @@
import { getShareLink } from "@/services/shareService";
import { notFound } from "next/navigation";
import { FlashcardViewer } from "@/components/flashcards/FlashcardViewer";
import { QuizViewer } from "@/components/quizzes/QuizViewer";
import Link from "next/link";
interface SharedPageProps {
params: Promise<{
classSlug: string;
type: string;
token: string;
}>;
}
export default async function SharedPage(props: SharedPageProps) {
const { classSlug, type, token } = await props.params;
if (type !== "flashcards" && type !== "quizzes") {
notFound();
}
const link = await getShareLink(token);
if (!link) {
notFound();
}
// Validate that the link matches the URL structure
if (type === "flashcards" && !link.deck) notFound();
if (type === "quizzes" && !link.quizSet) notFound();
// Validate class slug matches
const targetClassSlug = link.deck ? link.deck.class.slug : link.quizSet?.class.slug;
if (targetClassSlug !== classSlug) {
notFound();
}
return (
<div className="min-h-screen bg-bg-base flex flex-col">
{/* Read-only Header */}
<header className="bg-bg-surface border-b border-border-light shadow-sm sticky top-0 z-10">
<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">
<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>
Shared {type === "flashcards" ? "Deck" : "Quiz"}
</div>
<div className="flex items-center gap-3">
<span className="text-sm font-medium px-3 py-1 bg-badge-bg text-badge-text rounded-full">
Read Only
</span>
<Link
href="/login"
className="text-sm font-medium text-primary hover:text-primary-hover transition-colors"
>
Sign In
</Link>
</div>
</div>
</div>
</header>
{/* 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="mb-8 text-center">
<h1 className="text-2xl font-bold text-text-heading mb-2">
{link.deck ? link.deck.name : link.quizSet?.name}
</h1>
<p className="text-text-secondary">
From class: <span className="font-medium text-text-heading">{link.deck ? link.deck.class.name : link.quizSet?.class.name}</span>
</p>
</div>
{type === "flashcards" && link.deck && (
<FlashcardViewer cards={link.deck.cards} deckId={link.deck.id} isShared={true} />
)}
{type === "quizzes" && link.quizSet && (
<QuizViewer
quiz={{
...link.quizSet,
progress: [], // Start fresh since it's shared
}}
retakeIds={null}
isShared={true}
onFinished={() => {
// In shared mode, finishes local results display, when closed we can reload
// but QuizViewer doesn't handle the restart well in shared mode if we just close.
// We'll let it stay on results or the user can refresh.
}}
/>
)}
</div>
</main>
</div>
);
}

View file

@ -0,0 +1,243 @@
"use client";
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface Card {
id: string;
front: string;
back: string;
}
interface CardManagerProps {
cards: Card[];
deckId: string;
onCardsChanged: () => void;
}
export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps) {
const [editingId, setEditingId] = useState<string | null>(null);
const [editFront, setEditFront] = useState("");
const [editBack, setEditBack] = useState("");
const [showAdd, setShowAdd] = useState(false);
const [newFront, setNewFront] = useState("");
const [newBack, setNewBack] = useState("");
const [saving, setSaving] = useState(false);
function startEdit(card: Card) {
setEditingId(card.id);
setEditFront(card.front);
setEditBack(card.back);
}
async function saveEdit() {
if (!editingId) return;
setSaving(true);
try {
await fetch(`/api/cards/${editingId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ front: editFront, back: editBack }),
});
setEditingId(null);
onCardsChanged();
} finally {
setSaving(false);
}
}
async function deleteCard(id: string) {
if (!confirm("Delete this card?")) return;
await fetch(`/api/cards/${id}`, { method: "DELETE" });
onCardsChanged();
}
async function addCard() {
if (!newFront.trim() || !newBack.trim()) return;
setSaving(true);
try {
await fetch(`/api/decks/${deckId}/cards`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ front: newFront.trim(), back: newBack.trim() }),
});
setNewFront("");
setNewBack("");
setShowAdd(false);
onCardsChanged();
} finally {
setSaving(false);
}
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-text-secondary">
{cards.length} {cards.length === 1 ? "card" : "cards"}
</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"
>
<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" />
</svg>
Add Card
</button>
</div>
{/* Card list */}
{cards.map((card, index) => (
<div
key={card.id}
className="bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] overflow-hidden"
>
{editingId === card.id ? (
<div className="p-4 space-y-3">
<div>
<label className="block text-xs font-medium text-text-muted uppercase tracking-wider mb-1">
Front
</label>
<textarea
value={editFront}
onChange={(e) => setEditFront(e.target.value)}
rows={3}
className="w-full px-3 py-2 rounded-lg border border-border bg-bg-surface-alt/50 text-sm text-text-body focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all resize-y"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-muted uppercase tracking-wider mb-1">
Back
</label>
<textarea
value={editBack}
onChange={(e) => setEditBack(e.target.value)}
rows={3}
className="w-full px-3 py-2 rounded-lg border border-border bg-bg-surface-alt/50 text-sm text-text-body focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all resize-y"
/>
</div>
<div className="flex gap-2">
<button
onClick={saveEdit}
disabled={saving}
className="px-3 py-1.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover disabled:opacity-50 transition-all cursor-pointer"
>
{saving ? "Saving..." : "Save"}
</button>
<button
onClick={() => setEditingId(null)}
className="px-3 py-1.5 rounded-lg border border-border text-sm text-text-secondary hover:bg-bg-surface-alt transition-all cursor-pointer"
>
Cancel
</button>
</div>
</div>
) : (
<div className="flex items-stretch">
<div className="flex-1 p-4 grid grid-cols-2 gap-4">
<div>
<span className="block text-xs font-medium text-text-muted uppercase tracking-wider mb-1">
Front
</span>
<div className="markdown-content text-sm text-text-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{card.front}
</ReactMarkdown>
</div>
</div>
<div>
<span className="block text-xs font-medium text-text-muted uppercase tracking-wider mb-1">
Back
</span>
<div className="markdown-content text-sm text-text-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{card.back}
</ReactMarkdown>
</div>
</div>
</div>
<div className="flex flex-col border-l border-border-light">
<button
onClick={() => startEdit(card)}
className="flex-1 px-3 text-text-muted hover:text-primary hover:bg-bg-surface-alt transition-all cursor-pointer"
title="Edit"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => deleteCard(card.id)}
className="flex-1 px-3 text-text-muted hover:text-error hover:bg-error-bg transition-all cursor-pointer border-t border-border-light"
title="Delete"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
)}
</div>
))}
{/* Add card form */}
{showAdd && (
<div className="bg-bg-surface rounded-xl border-2 border-dashed border-primary/30 p-4 space-y-3 animate-slide-up">
<div>
<label className="block text-xs font-medium text-text-muted uppercase tracking-wider mb-1">
Front
</label>
<textarea
value={newFront}
onChange={(e) => setNewFront(e.target.value)}
rows={2}
placeholder="Question or term..."
autoFocus
className="w-full px-3 py-2 rounded-lg border border-border bg-bg-surface-alt/50 text-sm text-text-body placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all resize-y"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-muted uppercase tracking-wider mb-1">
Back
</label>
<textarea
value={newBack}
onChange={(e) => setNewBack(e.target.value)}
rows={2}
placeholder="Answer or definition..."
className="w-full px-3 py-2 rounded-lg border border-border bg-bg-surface-alt/50 text-sm text-text-body placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all resize-y"
/>
</div>
<div className="flex gap-2">
<button
onClick={addCard}
disabled={saving || !newFront.trim() || !newBack.trim()}
className="px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover disabled:opacity-50 transition-all cursor-pointer"
>
{saving ? "Adding..." : "Add Card"}
</button>
<button
onClick={() => {
setShowAdd(false);
setNewFront("");
setNewBack("");
}}
className="px-4 py-2 rounded-lg border border-border text-sm text-text-secondary hover:bg-bg-surface-alt transition-all cursor-pointer"
>
Cancel
</button>
</div>
</div>
)}
{cards.length === 0 && !showAdd && (
<div className="text-center py-12 text-text-muted">
<p>No cards yet. Add your first card above.</p>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,449 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface Card {
id: string;
front: string;
back: string;
}
interface ProgressData {
mode: "SEQUENTIAL" | "SHUFFLED";
currentIndex: number;
orderJson: string;
cardResultsJson: string | null;
}
interface FlashcardViewerProps {
cards: Card[];
deckId: string;
isShared?: boolean;
initialProgress?: ProgressData | null;
}
type CardResult = "correct" | "missed";
export function FlashcardViewer({ cards, deckId, isShared = false, initialProgress }: FlashcardViewerProps) {
const [order, setOrder] = useState<string[]>(
initialProgress ? JSON.parse(initialProgress.orderJson) : []
);
const [currentIndex, setCurrentIndex] = useState(
initialProgress ? initialProgress.currentIndex : 0
);
const [isFlipped, setIsFlipped] = useState(false);
const [hasFlippedOnce, setHasFlippedOnce] = useState(false);
const [results, setResults] = useState<Record<string, CardResult>>(
initialProgress && initialProgress.cardResultsJson
? JSON.parse(initialProgress.cardResultsJson)
: {}
);
const [mode, setMode] = useState<"SEQUENTIAL" | "SHUFFLED">(
initialProgress ? initialProgress.mode : "SEQUENTIAL"
);
const [started, setStarted] = useState(!!initialProgress);
// If progress is provided and index is already at or past the end, it means completed
const [completed, setCompleted] = useState(
initialProgress
? initialProgress.currentIndex >= JSON.parse(initialProgress.orderJson).length
: false
);
const [swipeClass, setSwipeClass] = useState("");
const cardRef = useRef<HTMLDivElement>(null);
// Touch/drag state
const dragRef = useRef({ startX: 0, currentX: 0, isDragging: false });
const currentCard = order.length > 0 ? cards.find((c) => c.id === order[currentIndex]) : null;
const correctCount = Object.values(results).filter((r) => r === "correct").length;
const missedCount = Object.values(results).filter((r) => r === "missed").length;
const totalGraded = correctCount + missedCount;
// Start a study session
function startSession(selectedMode: "SEQUENTIAL" | "SHUFFLED") {
setMode(selectedMode);
let newOrder: string[];
if (selectedMode === "SHUFFLED") {
newOrder = [...cards.map((c) => c.id)];
for (let i = newOrder.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[newOrder[i], newOrder[j]] = [newOrder[j], newOrder[i]];
}
} else {
newOrder = cards.map((c) => c.id);
}
setOrder(newOrder);
setCurrentIndex(0);
setResults({});
setIsFlipped(false);
setHasFlippedOnce(false);
setCompleted(false);
setStarted(true);
// Save initial progress
saveProgress(newOrder, 0, {}, selectedMode);
}
// Grade the current card
const gradeCard = useCallback(
(grade: CardResult) => {
if (!currentCard || !hasFlippedOnce) return;
const newResults = { ...results, [currentCard.id]: grade };
setResults(newResults);
// Animate
setSwipeClass(grade === "correct" ? "animate-swipe-right" : "animate-swipe-left");
setTimeout(() => {
setSwipeClass("");
setIsFlipped(false);
setHasFlippedOnce(false);
if (currentIndex + 1 >= order.length) {
setCompleted(true);
saveProgress(order, currentIndex, newResults, mode);
} else {
const nextIndex = currentIndex + 1;
setCurrentIndex(nextIndex);
saveProgress(order, nextIndex, newResults, mode);
}
}, 350);
},
[currentCard, hasFlippedOnce, results, currentIndex, order, mode]
);
// Keyboard shortcuts
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (!started || completed) return;
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
setIsFlipped((prev) => {
if (!prev) setHasFlippedOnce(true);
return !prev;
});
} else if (e.key === "ArrowRight" && hasFlippedOnce) {
gradeCard("correct");
} else if (e.key === "ArrowLeft" && hasFlippedOnce) {
gradeCard("missed");
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [started, completed, isFlipped, gradeCard]);
// Touch handlers for swipe
function handleTouchStart(e: React.TouchEvent) {
if (!isFlipped) return;
dragRef.current = {
startX: e.touches[0].clientX,
currentX: e.touches[0].clientX,
isDragging: true,
};
}
function handleTouchMove(e: React.TouchEvent) {
if (!dragRef.current.isDragging) return;
dragRef.current.currentX = e.touches[0].clientX;
const dx = dragRef.current.currentX - dragRef.current.startX;
if (cardRef.current) {
cardRef.current.style.transform = `translateX(${dx}px) rotate(${dx * 0.05}deg)`;
}
}
function handleTouchEnd() {
if (!dragRef.current.isDragging) return;
dragRef.current.isDragging = false;
const dx = dragRef.current.currentX - dragRef.current.startX;
if (cardRef.current) {
cardRef.current.style.transform = "";
}
if (Math.abs(dx) > 80) {
gradeCard(dx > 0 ? "correct" : "missed");
}
}
// Save progress (debounced / fire-and-forget)
function saveProgress(
orderArr: string[],
index: number,
cardResults: Record<string, CardResult>,
m: "SEQUENTIAL" | "SHUFFLED"
) {
if (isShared) return;
fetch("/api/progress", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contentType: "DECK",
contentId: deckId,
mode: m,
currentIndex: index,
orderJson: JSON.stringify(orderArr),
cardResultsJson: JSON.stringify(cardResults),
}),
}).catch(() => {}); // fire-and-forget
}
// Restart handlers
function restartFullSet() {
startSession(mode);
}
function redoMissed() {
const missedIds = Object.entries(results)
.filter(([, r]) => r === "missed")
.map(([id]) => id);
if (missedIds.length === 0) return;
const newOrder = mode === "SHUFFLED"
? (() => {
const arr = [...missedIds];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
})()
: missedIds;
setOrder(newOrder);
setCurrentIndex(0);
setResults({});
setIsFlipped(false);
setHasFlippedOnce(false);
setCompleted(false);
saveProgress(newOrder, 0, {}, mode);
}
// Mode selection (not yet started)
if (!started) {
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="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-bg-callout mb-4">
<svg className="w-7 h-7 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>
<h3 className="text-lg font-semibold text-text-heading mb-1">
{cards.length} cards ready
</h3>
<p className="text-sm text-text-secondary mb-6">
Choose how you&apos;d like to study
</p>
<div className="flex flex-col gap-3">
<button
onClick={() => startSession("SEQUENTIAL")}
className="w-full py-3 px-4 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
>
Sequential Order
</button>
<button
onClick={() => startSession("SHUFFLED")}
className="w-full py-3 px-4 rounded-lg border border-border text-text-heading font-medium hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
>
Shuffle Cards
</button>
</div>
</div>
</div>
);
}
// Completed summary
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="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>
<div className="flex justify-center gap-6 mb-6">
<div className="text-center">
<div className="text-2xl font-bold text-success">{correctCount}</div>
<div className="text-xs text-text-muted">Correct</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-error">{missedCount}</div>
<div className="text-xs text-text-muted">Missed</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-text-heading">{order.length}</div>
<div className="text-xs text-text-muted">Total</div>
</div>
</div>
{correctCount > 0 && (
<div className="w-full bg-bg-surface-alt rounded-full h-2 mb-6">
<div
className="h-2 rounded-full bg-success transition-all duration-500"
style={{ width: `${(correctCount / order.length) * 100}%` }}
/>
</div>
)}
<div className="flex flex-col gap-3">
<button
onClick={restartFullSet}
className="w-full py-3 px-4 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
>
Restart Full Set
</button>
{missedCount > 0 && (
<button
onClick={redoMissed}
className="w-full py-3 px-4 rounded-lg border border-border text-text-heading font-medium hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
>
Redo Missed Only ({missedCount} {missedCount === 1 ? "card" : "cards"})
</button>
)}
</div>
</div>
</div>
);
}
// Study view
return (
<div className="flex flex-col items-center">
{/* Running tally */}
<div className="w-full max-w-4xl mb-6">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-3">
<span className="text-success font-medium">{correctCount} </span>
<span className="text-error font-medium">{missedCount} </span>
</div>
<span className="text-text-muted">
{currentIndex + 1} of {order.length}
</span>
</div>
{/* Progress bar */}
<div className="w-full bg-bg-surface-alt rounded-full h-1.5 mt-2">
<div
className="h-1.5 rounded-full bg-primary transition-all duration-300"
style={{ width: `${((currentIndex + 1) / order.length) * 100}%` }}
/>
</div>
</div>
{/* Card */}
{currentCard && (
<div className="w-full max-w-4xl">
<div
ref={cardRef}
className={`perspective-1000 cursor-pointer select-none relative ${swipeClass}`}
onClick={() => {
setIsFlipped(!isFlipped);
if (!isFlipped) setHasFlippedOnce(true);
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
style={{ transition: swipeClass ? "none" : "transform 0.15s ease-out" }}
>
<div
key={currentCard.id}
className={`relative w-full min-h-[450px] md:min-h-[550px] preserve-3d ${!swipeClass ? "transition-transform duration-500" : ""} ${
isFlipped ? "rotate-y-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">
<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">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{currentCard.front}
</ReactMarkdown>
</div>
<p className="mt-6 text-xs text-text-muted">
Tap or press Space to flip
</p>
</div>
{/* Back */}
<div className="absolute inset-0 backface-hidden rotate-y-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">
<span className="absolute top-3 right-4 text-xs font-medium text-text-muted uppercase tracking-wider">
Back
</span>
<div className="markdown-content text-center text-lg md:text-xl text-text-body leading-relaxed max-h-[400px] md:max-h-[450px] overflow-y-auto w-full">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{currentCard.back}
</ReactMarkdown>
</div>
</div>
</div>
{/* Grading Overlay */}
{swipeClass && (
<div
className={`absolute inset-0 z-20 flex items-center justify-center rounded-2xl border-2 md:border-4 transition-colors duration-150 ${
swipeClass === "animate-swipe-left"
? "border-error bg-bg-surface/40 backdrop-blur-[2px]"
: "border-success bg-bg-surface/40 backdrop-blur-[2px]"
}`}
>
<span
className={`text-3xl md:text-5xl font-bold px-8 py-4 rounded-3xl -rotate-12 uppercase tracking-wider shadow-lg ${
swipeClass === "animate-swipe-left"
? "text-error bg-error-bg/90"
: "text-success bg-success-bg/90"
}`}
>
{swipeClass === "animate-swipe-left" ? "Still learning" : "Know"}
</span>
</div>
)}
</div>
{/* Grading buttons (visible after first flip) */}
{hasFlippedOnce && (
<div className="flex items-center justify-center gap-4 mt-6 animate-fade-in">
<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"
>
<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" />
</svg>
Missed
</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"
>
<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" />
</svg>
Got It
</button>
</div>
)}
{/* Keyboard hint */}
<div className="text-center mt-4 text-xs text-text-muted">
{hasFlippedOnce
? "← Missed · Got It →"
: "Space to flip"}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,187 @@
"use client";
import { useState } from "react";
interface CreateTabProps {
classId: string;
onCreated: () => void;
}
export function CreateTab({ classId, onCreated }: CreateTabProps) {
const [deckName, setDeckName] = useState("");
const [description, setDescription] = useState("");
const [cards, setCards] = useState([
{ id: "1", front: "", back: "" },
{ id: "2", front: "", back: "" },
{ id: "3", front: "", back: "" },
]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
function addCard() {
setCards([
...cards,
{ id: Date.now().toString(), front: "", back: "" },
]);
}
function removeCard(id: string) {
if (cards.length <= 1) return;
setCards(cards.filter((c) => c.id !== id));
}
function updateCard(id: string, field: "front" | "back", value: string) {
setCards(
cards.map((c) => (c.id === id ? { ...c, [field]: value } : c))
);
}
async function handleCreate() {
setError(null);
if (!deckName.trim()) {
setError("Deck title is required.");
return;
}
const validCards = cards.filter((c) => c.front.trim() && c.back.trim());
if (validCards.length === 0) {
setError("Please add at least one complete card.");
return;
}
setSaving(true);
try {
const res = await fetch("/api/decks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
classId,
name: deckName,
data: {
type: "flashcards",
deckName: deckName,
description: description || undefined,
cards: validCards.map((c) => ({
front: c.front,
back: c.back,
})),
},
}),
});
if (!res.ok) {
throw new Error("Failed to create deck");
}
onCreated();
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
}
return (
<div className="space-y-6">
{error && (
<div className="p-3 bg-error-bg text-error rounded-lg text-sm">
{error}
</div>
)}
{/* Deck Metadata */}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-heading mb-1">
Title
</label>
<input
type="text"
value={deckName}
onChange={(e) => setDeckName(e.target.value)}
placeholder="Enter a title, e.g. Biology Ch. 1"
className="w-full px-4 py-2.5 rounded-lg border border-border bg-bg-surface-alt/50 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-heading mb-1">
Description (optional)
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add a description..."
className="w-full px-4 py-2.5 rounded-lg border border-border bg-bg-surface-alt/50 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200"
/>
</div>
</div>
{/* Cards */}
<div className="space-y-4">
{cards.map((card, index) => (
<div
key={card.id}
className="bg-bg-surface-alt rounded-xl border border-border-light overflow-hidden"
>
<div className="flex items-center justify-between px-4 py-2 border-b border-border-light bg-bg-base/50">
<span className="font-semibold text-text-muted">{index + 1}</span>
<button
onClick={() => removeCard(card.id)}
disabled={cards.length <= 1}
className="p-1.5 text-text-muted hover:text-error transition-colors disabled:opacity-30 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<input
type="text"
value={card.front}
onChange={(e) => updateCard(card.id, "front", e.target.value)}
placeholder="Enter term"
className="w-full px-0 py-2 bg-transparent border-0 border-b-2 border-border-light focus:border-primary focus:ring-0 text-sm transition-colors outline-none"
/>
<span className="block mt-1 text-[10px] font-semibold tracking-wider text-text-muted uppercase">
Term
</span>
</div>
<div>
<input
type="text"
value={card.back}
onChange={(e) => updateCard(card.id, "back", e.target.value)}
placeholder="Enter definition"
className="w-full px-0 py-2 bg-transparent border-0 border-b-2 border-border-light focus:border-primary focus:ring-0 text-sm transition-colors outline-none"
/>
<span className="block mt-1 text-[10px] font-semibold tracking-wider text-text-muted uppercase">
Definition
</span>
</div>
</div>
</div>
))}
</div>
<div className="flex flex-col items-center gap-4 pt-4">
<button
onClick={addCard}
className="px-6 py-2.5 rounded-full bg-bg-surface-alt border border-border-light text-sm font-medium text-text-heading hover:bg-bg-callout transition-colors cursor-pointer"
>
+ Add a card
</button>
<button
onClick={handleCreate}
disabled={saving}
className="w-full md:w-auto px-8 py-3 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-colors cursor-pointer disabled:opacity-50"
>
{saving ? "Creating..." : "Create"}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,108 @@
"use client";
import { useState, useEffect } from "react";
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" }) {
const [instructions, setInstructions] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
fetch(`/api/settings/llm-instructions?type=${importType}`)
.then((res) => res.json())
.then((data) => setInstructions(data.value))
.finally(() => setLoading(false));
}, [importType]);
async function handleSave() {
setSaving(true);
try {
await fetch(`/api/settings/llm-instructions?type=${importType}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: instructions }),
});
} finally {
setSaving(false);
}
}
async function handleReset() {
if (!confirm("Reset to default instructions?")) return;
setSaving(true);
try {
const res = await fetch(`/api/settings/llm-instructions?type=${importType}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: "__RESET__" }),
});
const data = await res.json();
setInstructions(data.value);
} finally {
setSaving(false);
}
}
async function handleCopy() {
await navigator.clipboard.writeText(instructions);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
if (loading) {
return <div className="animate-subtle-pulse text-text-muted">Loading instructions...</div>;
}
return (
<div className="space-y-4">
<p className="text-sm text-text-secondary">
Copy these instructions and paste them into an LLM chat along with your study material. The LLM will generate JSON you can paste into the Import tab.
</p>
<textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
rows={14}
className="w-full px-4 py-3 rounded-lg border border-border bg-bg-surface-alt/50 text-sm text-text-body font-mono leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200 resize-y"
/>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
>
{copied ? (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Copied!
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
Copy to Clipboard
</>
)}
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 rounded-lg border border-border text-sm text-text-secondary hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer disabled:opacity-50"
>
{saving ? "Saving..." : "Save Changes"}
</button>
<button
onClick={handleReset}
disabled={saving}
className="px-4 py-2 rounded-lg text-sm text-text-muted hover:text-error hover:bg-error-bg transition-all duration-200 cursor-pointer disabled:opacity-50"
>
Reset to Default
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,112 @@
"use client";
import { useState } from "react";
import { GenerateTab } from "./GenerateTab";
import { ImportTab } from "./ImportTab";
import { CreateTab } from "./CreateTab";
interface ImportModalProps {
classId: string;
importType: "flashcards" | "quizzes";
onClose: () => void;
onImported: () => void;
}
export function ImportModal({
classId,
importType,
onClose,
onImported,
}: ImportModalProps) {
const [activeTab, setActiveTab] = useState<"generate" | "import" | "create">("import");
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm animate-fade-in"
onClick={onClose}
/>
{/* 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">
{/* Header */}
<div className="flex items-center justify-between px-6 pt-5 pb-0">
<h2 className="text-lg font-semibold text-text-heading">
Import {importType === "flashcards" ? "Flashcard Deck" : "Quiz"}
</h2>
<button
onClick={onClose}
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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Tabs */}
<div className="px-6 pt-4">
<div className="flex gap-0 border-b border-border-light">
<button
onClick={() => setActiveTab("generate")}
className={`relative px-4 py-2.5 text-sm font-medium transition-colors cursor-pointer ${
activeTab === "generate"
? "text-primary"
: "text-text-muted hover:text-text-heading"
}`}
>
Generate
{activeTab === "generate" && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full" />
)}
</button>
<button
onClick={() => setActiveTab("import")}
className={`relative px-4 py-2.5 text-sm font-medium transition-colors cursor-pointer ${
activeTab === "import"
? "text-primary"
: "text-text-muted hover:text-text-heading"
}`}
>
Import
{activeTab === "import" && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full" />
)}
</button>
{importType === "flashcards" && (
<button
onClick={() => setActiveTab("create")}
className={`relative px-4 py-2.5 text-sm font-medium transition-colors cursor-pointer ${
activeTab === "create"
? "text-primary"
: "text-text-muted hover:text-text-heading"
}`}
>
Create
{activeTab === "create" && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full" />
)}
</button>
)}
</div>
</div>
{/* Tab content */}
<div className="flex-1 overflow-y-auto p-6 md:p-8">
{activeTab === "generate" && <GenerateTab importType={importType} />}
{activeTab === "import" && (
<ImportTab
classId={classId}
importType={importType}
onImported={onImported}
/>
)}
{activeTab === "create" && importType === "flashcards" && (
<CreateTab classId={classId} onCreated={onImported} />
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,223 @@
"use client";
import { useState } from "react";
import { parseAndRepairJson } from "@/lib/jsonRepair";
import {
flashcardImportSchema,
quizImportSchema,
} from "@/lib/validation/importSchemas";
interface ImportTabProps {
classId: string;
importType: "flashcards" | "quizzes";
onImported: () => void;
}
interface PreviewData {
type: string;
name: string;
description?: string;
itemCount: number;
categories?: string[];
wasRepaired: boolean;
data: unknown;
}
export function ImportTab({ classId, importType, onImported }: ImportTabProps) {
const [jsonText, setJsonText] = useState("");
const [name, setName] = useState("");
const [preview, setPreview] = useState<PreviewData | null>(null);
const [error, setError] = useState("");
const [importing, setImporting] = useState(false);
function handleJsonChange(text: string) {
setJsonText(text);
setError("");
setPreview(null);
if (!text.trim()) return;
// Run the parse → repair → validate pipeline
const result = parseAndRepairJson(text);
if (!result.success) {
setError(result.error || "Failed to parse JSON");
return;
}
// Validate with Zod
const schema =
importType === "flashcards" ? flashcardImportSchema : quizImportSchema;
const validated = schema.safeParse(result.data);
if (!validated.success) {
const issues = validated.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("\n");
setError(`Validation errors:\n${issues}`);
return;
}
// Build preview
const data = validated.data;
if (data.type === "flashcards") {
setPreview({
type: "flashcards",
name: data.deckName,
description: data.description,
itemCount: data.cards.length,
wasRepaired: result.wasRepaired,
data: data,
});
if (!name) setName(data.deckName);
} else {
const categories = [
...new Set(data.questions.map((q) => q.category.trim().toLowerCase())),
];
setPreview({
type: "quiz",
name: data.quizName,
description: data.description,
itemCount: data.questions.length,
categories,
wasRepaired: result.wasRepaired,
data: data,
});
if (!name) setName(data.quizName);
}
}
async function handleImport() {
if (!preview || !name.trim()) return;
setImporting(true);
try {
const endpoint =
importType === "flashcards" ? "/api/decks" : "/api/quizzes";
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
classId,
data: preview.data,
name: name.trim(),
}),
});
if (!res.ok) {
const err = await res.json();
setError(err.error || "Import failed");
return;
}
onImported();
} catch {
setError("Network error during import");
} finally {
setImporting(false);
}
}
return (
<div className="space-y-4">
{/* Name field */}
<div>
<label className="block text-sm font-medium text-text-secondary mb-1.5">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={
importType === "flashcards" ? "Deck name..." : "Quiz name..."
}
className="w-full px-3.5 py-2.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200"
/>
</div>
{/* JSON paste area */}
<div>
<label className="block text-sm font-medium text-text-secondary mb-1.5">
Paste JSON
</label>
<textarea
value={jsonText}
onChange={(e) => handleJsonChange(e.target.value)}
rows={10}
placeholder="Paste the JSON output from your LLM here..."
className="w-full px-4 py-3 rounded-lg border border-border bg-bg-surface-alt/50 text-sm text-text-body font-mono leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200 resize-y"
/>
</div>
{/* Error display */}
{error && (
<div className="bg-error-bg border border-error/20 rounded-lg px-4 py-3">
<div className="flex items-start gap-2">
<svg
className="w-4 h-4 text-error flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<pre className="text-sm text-error whitespace-pre-wrap font-mono">
{error}
</pre>
</div>
</div>
)}
{/* Preview */}
{preview && (
<div className="bg-bg-callout border border-primary/10 rounded-lg px-4 py-4 space-y-3">
{preview.wasRepaired && (
<div className="flex items-center gap-2 text-xs text-primary bg-primary/5 rounded px-2.5 py-1.5">
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
JSON was auto-repaired (minor syntax fixes)
</div>
)}
<div className="text-sm text-text-heading font-medium">
Preview: {preview.itemCount}{" "}
{preview.type === "flashcards" ? "cards" : "questions"}
</div>
{preview.description && (
<p className="text-sm text-text-secondary">{preview.description}</p>
)}
{preview.categories && preview.categories.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{preview.categories.map((cat) => (
<span
key={cat}
className="inline-flex px-2.5 py-1 rounded-full bg-badge-bg text-badge-text text-xs font-medium"
>
{cat}
</span>
))}
</div>
)}
</div>
)}
{/* Import button */}
<button
onClick={handleImport}
disabled={!preview || !name.trim() || importing}
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"
>
{importing ? "Importing..." : "Confirm Import"}
</button>
</div>
);
}

View file

@ -0,0 +1,61 @@
"use client";
import { useState } from "react";
import { QuizResults } from "./QuizResults";
interface AttemptHistoryProps {
quiz: any;
attempts: any[];
onRetake: (missedIds: string[]) => void;
}
export function AttemptHistory({ quiz, attempts, onRetake }: AttemptHistoryProps) {
const [selectedAttempt, setSelectedAttempt] = useState<any | null>(null);
if (selectedAttempt) {
return (
<QuizResults
quiz={quiz}
attempt={selectedAttempt}
onRetake={onRetake}
onClose={() => setSelectedAttempt(null)}
/>
);
}
return (
<div className="space-y-4">
<h2 className="text-xl font-bold text-text-heading mb-6">Quiz History</h2>
{attempts.map((attempt) => {
const pct = attempt.maxScore > 0 ? (attempt.score / attempt.maxScore) * 100 : 0;
const dateStr = new Date(attempt.completedAt).toLocaleString();
let colorClass = "text-primary bg-primary/10 border-primary/20";
if (pct < 60) colorClass = "text-error bg-error-bg border-error/20";
else if (pct >= 80) colorClass = "text-success bg-success-bg/60 border-success/30";
return (
<div
key={attempt.id}
onClick={() => setSelectedAttempt(attempt)}
className="flex items-center justify-between bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/30 p-5 cursor-pointer transition-all duration-200"
>
<div>
<div className="text-text-heading font-medium mb-1">
{dateStr}
</div>
<div className="text-sm text-text-secondary">
{attempt.isPartialRetake ? "Partial Retake" : "Full Attempt"}
</div>
</div>
<div className={`px-4 py-2 rounded-lg border font-bold text-lg ${colorClass}`}>
{Math.round(pct)}%
</div>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,67 @@
"use client";
import { scoreQuestion } from "@/lib/scoring";
interface CategoryBreakdownProps {
quiz: any;
attempt: any;
answeredIds: string[];
answers: Record<string, string[]>;
}
export function CategoryBreakdown({ quiz, attempt, answeredIds, answers }: CategoryBreakdownProps) {
// Aggregate stats per category
const stats: Record<string, { earned: number; possible: number }> = {};
answeredIds.forEach((qId) => {
const q = quiz.questions.find((x: any) => x.id === qId);
if (!q) return;
const cat = q.category.trim().toLowerCase();
if (!stats[cat]) {
stats[cat] = { earned: 0, possible: 0 };
}
const formattedQ = { id: q.id, type: q.type, options: q.options };
const points = scoreQuestion(formattedQ, answers[q.id] || []);
stats[cat].earned += points;
stats[cat].possible += 1;
});
const categories = Object.keys(stats).sort();
return (
<div className="space-y-4">
{categories.map((cat) => {
const { earned, possible } = stats[cat];
const pct = possible > 0 ? (earned / possible) * 100 : 0;
let colorClass = "bg-primary";
if (pct < 60) colorClass = "bg-error";
else if (pct >= 80) colorClass = "bg-success";
return (
<div key={cat} className="flex items-center gap-4">
<div className="w-32 text-sm font-medium text-text-heading capitalize truncate" title={cat}>
{cat}
</div>
<div className="flex-1">
<div className="w-full bg-bg-surface-alt rounded-full h-2.5">
<div
className={`h-2.5 rounded-full ${colorClass} transition-all duration-500`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
<div className="w-24 text-right text-sm text-text-secondary">
{earned.toFixed(1)} / {possible}
</div>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,151 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { CategoryBreakdown } from "./CategoryBreakdown";
import { scoreQuestion } from "@/lib/scoring";
interface QuizResultsProps {
quiz: any; // QuizData
attempt: any; // QuizAttempt
onRetake: (missedIds: string[]) => void;
onClose: () => void;
}
export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsProps) {
let answers: Record<string, string[]> = {};
try {
answers = JSON.parse(attempt.answersJson);
} catch {}
// Determine non-full-credit questions for review list
const reviewItems: any[] = [];
const missedIds: string[] = [];
const answeredIds = Object.keys(answers);
const questionsToReview = quiz.questions.filter((q: any) => answeredIds.includes(q.id));
questionsToReview.forEach((q: any) => {
const formattedQ = {
id: q.id,
type: q.type,
options: q.options,
};
const score = scoreQuestion(formattedQ, answers[q.id] || []);
// If not full credit (1.0), add to review and missed arrays
if (score < 1) {
missedIds.push(q.id);
reviewItems.push({
question: q,
score,
selections: answers[q.id] || []
});
}
});
const percentage = attempt.maxScore > 0 ? (attempt.score / attempt.maxScore) * 100 : 0;
return (
<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">
{percentage >= 80 && (
<div className="absolute top-0 left-0 w-full h-2 bg-success"></div>
)}
{percentage < 80 && percentage >= 60 && (
<div className="absolute top-0 left-0 w-full h-2 bg-primary"></div>
)}
{percentage < 60 && (
<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>
{attempt.isPartialRetake && (
<p className="text-sm text-text-secondary mb-4">(Partial Retake)</p>
)}
<div className="flex justify-center items-end gap-2 mb-6">
<span className="text-5xl font-black text-text-heading">{Math.round(percentage)}%</span>
<span className="text-lg text-text-muted mb-1 font-medium">({attempt.score.toFixed(2)} / {attempt.maxScore})</span>
</div>
<div className="flex justify-center gap-4">
<button
onClick={onClose}
className="px-6 py-2.5 rounded-lg border border-border text-text-heading font-medium hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
>
Back to Quizzes
</button>
{missedIds.length > 0 && (
<button
onClick={() => onRetake(missedIds)}
className="px-6 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
>
Retake Missed ({missedIds.length})
</button>
)}
</div>
</div>
{/* Category Breakdown */}
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-6 md:p-8">
<h3 className="text-lg font-bold text-text-heading mb-6">Category Breakdown</h3>
<CategoryBreakdown quiz={quiz} attempt={attempt} answeredIds={answeredIds} answers={answers} />
</div>
{/* Review List */}
{reviewItems.length > 0 && (
<div className="space-y-6">
<h3 className="text-xl font-bold text-text-heading px-2">Areas for Review</h3>
{reviewItems.map((item, idx) => {
const q = item.question;
const sels = item.selections;
return (
<div key={q.id} className="bg-bg-surface rounded-2xl border border-border-light shadow-sm overflow-hidden">
<div className="bg-error-bg/30 border-b border-error/20 px-6 py-3 flex items-center justify-between">
<span className="text-sm font-medium text-error">Score: {item.score.toFixed(2)} / 1</span>
<span className="text-xs font-bold uppercase tracking-wider text-text-muted">{q.category}</span>
</div>
<div className="p-6">
<div className="markdown-content text-text-heading mb-6 font-medium">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{q.prompt}</ReactMarkdown>
</div>
<div className="space-y-2 mb-6">
{q.options.map((opt: any) => {
const isSelected = sels.includes(opt.id);
let style = "text-text-body";
if (opt.isCorrect) style = "text-success font-medium bg-success-bg/50 border-success/30";
else if (isSelected && !opt.isCorrect) style = "text-error font-medium line-through bg-error-bg/50 border-error/30";
else style = "border-transparent";
return (
<div key={opt.id} className={`flex items-start gap-3 p-3 rounded-lg border ${style}`}>
<div className="pt-0.5">
{opt.isCorrect ? "✓" : (isSelected ? "✗" : "•")}
</div>
<div>{opt.text}</div>
</div>
);
})}
</div>
<div className="bg-bg-callout border border-primary/20 rounded-xl p-5">
<h4 className="text-xs font-bold text-text-heading uppercase tracking-wider mb-2">Rationale</h4>
<div className="markdown-content text-sm text-text-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{q.rationale}</ReactMarkdown>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,399 @@
"use client";
import { useState, useEffect } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { scoreQuestion } from "@/lib/scoring";
import { QuizResults } from "./QuizResults";
interface Option {
id: string;
text: string;
isCorrect: boolean;
}
interface Question {
id: string;
type: string;
prompt: string;
rationale: string;
category: string;
options: Option[];
}
interface QuizViewerProps {
quiz: {
id: string;
name: string;
questions: Question[];
progress: Array<{
mode: string;
currentIndex: number;
orderJson: string;
answersJson: string | null;
}>;
};
retakeIds: string[] | null;
isShared?: boolean;
onFinished: () => void;
}
export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: QuizViewerProps) {
const [order, setOrder] = useState<string[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [answers, setAnswers] = useState<Record<string, string[]>>({});
const [submittedAnswers, setSubmittedAnswers] = useState<string[]>([]);
const [shuffledOptions, setShuffledOptions] = useState<Record<string, Option[]>>({});
const [loading, setLoading] = useState(true);
const [resultsData, setResultsData] = useState<any | null>(null);
// Initialize session
useEffect(() => {
// Generate shuffled options once per session mount
const newShuffledOpts: Record<string, Option[]> = {};
for (const q of quiz.questions) {
newShuffledOpts[q.id] = [...q.options].sort(() => Math.random() - 0.5);
}
setShuffledOptions(newShuffledOpts);
// If retaking specific questions
if (retakeIds && retakeIds.length > 0) {
setOrder(retakeIds);
setCurrentIndex(0);
setAnswers({});
setSubmittedAnswers([]);
setLoading(false);
return;
}
// Otherwise, normal sequential logic checking for progress
const prog = quiz.progress?.find((p) => p.mode === "SEQUENTIAL");
if (prog) {
let savedOrder: string[] = [];
try {
savedOrder = JSON.parse(prog.orderJson);
} catch {
savedOrder = quiz.questions.map((q) => q.id);
}
let savedAnswers: Record<string, string[]> = {};
if (prog.answersJson) {
try {
savedAnswers = JSON.parse(prog.answersJson);
} catch {}
}
setOrder(savedOrder);
setCurrentIndex(Math.min(prog.currentIndex, savedOrder.length - 1));
setAnswers(savedAnswers);
setSubmittedAnswers(Object.keys(savedAnswers));
} else {
const freshOrder = quiz.questions.map((q) => q.id).sort(() => Math.random() - 0.5);
setOrder(freshOrder);
setCurrentIndex(0);
setAnswers({});
setSubmittedAnswers([]);
}
setLoading(false);
}, [quiz, retakeIds]);
// Persist progress as you go
function saveProgress(
idx: number,
ans: Record<string, string[]>
) {
if (retakeIds || isShared) return; // Don't persist retake partial state or shared mode into the main progress row
fetch("/api/progress", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contentType: "QUIZ",
contentId: quiz.id,
mode: "SEQUENTIAL",
currentIndex: idx,
orderJson: JSON.stringify(order),
answersJson: JSON.stringify(ans),
}),
}).catch(() => {});
}
if (loading) return <div>Loading quiz...</div>;
const currentQId = order[currentIndex];
const currentQ = quiz.questions.find((q) => q.id === currentQId);
const isSubmitted = currentQId ? submittedAnswers.includes(currentQId) : false;
const currentSelections = currentQId ? answers[currentQId] || [] : [];
// Compute running score
let runningScore = 0;
let maxPossibleSoFar = 0;
submittedAnswers.forEach((qId) => {
const q = quiz.questions.find((x) => x.id === qId);
if (!q) return;
maxPossibleSoFar += 1; // 1 point per question
const formattedQ = {
id: q.id,
type: q.type as "MULTIPLE_CHOICE" | "SATA",
options: q.options,
};
runningScore += scoreQuestion(formattedQ, answers[qId] || []);
});
function toggleOption(optId: string) {
if (isSubmitted || !currentQId || !currentQ) return;
if (currentQ.type === "MULTIPLE_CHOICE") {
setAnswers({ ...answers, [currentQId]: [optId] });
} else {
const prev = answers[currentQId] || [];
const updated = prev.includes(optId)
? prev.filter((id) => id !== optId)
: [...prev, optId];
setAnswers({ ...answers, [currentQId]: updated });
}
}
function handleSubmit() {
if (isSubmitted || !currentQId) return;
const newSubmitted = [...submittedAnswers, currentQId];
setSubmittedAnswers(newSubmitted);
saveProgress(currentIndex, answers);
}
function handleNext() {
if (currentIndex + 1 < order.length) {
const nextIdx = currentIndex + 1;
setCurrentIndex(nextIdx);
saveProgress(nextIdx, answers);
}
}
async function handleFinish() {
// If shared, grade it locally and show results, don't ping backend
if (isShared) {
// Simulate an attempt object
let runningScore = 0;
let maxScore = quiz.questions.length; // Actually, depends on retakeIds, but shared doesn't support retakeIds typically
if (retakeIds) maxScore = retakeIds.length;
const scoredItems = (retakeIds || quiz.questions.map(q => q.id)).map(qId => {
const q = quiz.questions.find((x) => x.id === qId);
if (!q) return 0;
const formattedQ = {
id: q.id,
type: q.type as "MULTIPLE_CHOICE" | "SATA",
options: q.options,
};
return scoreQuestion(formattedQ, answers[qId] || []);
});
runningScore = scoredItems.reduce((acc, curr) => acc + curr, 0);
const attempt = {
id: "shared-attempt",
score: runningScore,
maxScore: maxScore,
answersJson: JSON.stringify(answers),
isPartialRetake: false,
completedAt: new Date().toISOString(),
};
setResultsData(attempt);
return;
}
// Send to backend
const isPartialRetake = !!retakeIds;
try {
const res = await fetch(`/api/quizzes/${quiz.id}/attempt`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
answersJson: JSON.stringify(answers),
isPartialRetake,
}),
});
if (res.ok) {
const attempt = await res.json();
// Clear progress
if (!isPartialRetake) {
await fetch("/api/progress", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contentType: "QUIZ",
contentId: quiz.id,
mode: "SEQUENTIAL",
}),
});
}
setResultsData(attempt);
}
} catch (e) {
console.error(e);
}
}
if (resultsData) {
return (
<QuizResults
quiz={quiz}
attempt={resultsData}
onRetake={(missedIds: string[]) => {
// Restart this component with retakeIds
setOrder(missedIds);
setCurrentIndex(0);
setAnswers({});
setSubmittedAnswers([]);
setResultsData(null);
}}
onClose={onFinished}
/>
);
}
if (!currentQ) {
return <div>Question not found.</div>;
}
return (
<div className="flex flex-col items-center">
{/* Running Score Header */}
<div className="w-full max-w-2xl mb-6 flex items-center justify-between bg-bg-surface border border-border-light rounded-lg px-5 py-3 shadow-[var(--shadow-card)]">
<div>
<span className="text-sm font-medium text-text-secondary">Progress: </span>
<span className="text-sm font-bold text-text-heading">{currentIndex + 1} / {order.length}</span>
</div>
<div>
<span className="text-sm font-medium text-text-secondary">Score so far: </span>
<span className="text-sm font-bold text-primary">
{runningScore.toFixed(2)} / {maxPossibleSoFar}
</span>
</div>
</div>
{/* Question Card */}
<div className="w-full max-w-2xl bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] overflow-hidden mb-8">
{/* Header (Type & Category) */}
<div className="bg-bg-surface-alt border-b border-border-light px-6 py-4 flex items-center justify-between">
<span className="text-xs font-bold uppercase tracking-wider text-primary">
{currentQ.type === "MULTIPLE_CHOICE" ? "Multiple Choice" : "Select All That Apply"}
</span>
<span className="inline-flex px-2.5 py-1 rounded-full bg-badge-bg text-badge-text text-xs font-medium">
{currentQ.category}
</span>
</div>
<div className="p-6 md:p-8">
<div className="markdown-content text-lg text-text-heading mb-8 leading-relaxed font-medium">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{currentQ.prompt}
</ReactMarkdown>
</div>
<div className="space-y-3">
{(shuffledOptions[currentQId] || currentQ.options).map((opt) => {
const isSelected = currentSelections.includes(opt.id);
let stateClass = "border-border hover:border-primary/50 cursor-pointer";
let icon = null;
if (isSubmitted) {
stateClass = "cursor-default opacity-80";
if (opt.isCorrect) {
stateClass = "border-success bg-success-bg/30 text-text-heading";
icon = (
<svg className="w-5 h-5 text-success flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
);
} else if (isSelected && !opt.isCorrect) {
// Selected incorrectly
stateClass = "border-error bg-error-bg/30 text-text-heading";
icon = (
<svg className="w-5 h-5 text-error flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
</svg>
);
} else {
// Not selected, not correct
stateClass = "border-border-light bg-bg-surface-alt/50";
}
} else if (isSelected) {
stateClass = "border-primary bg-primary/5 ring-1 ring-primary";
}
return (
<div
key={opt.id}
onClick={() => toggleOption(opt.id)}
className={`flex items-start gap-4 p-4 rounded-xl border-2 transition-all duration-200 ${stateClass}`}
>
<div className="pt-0.5">
{icon ? icon : (
<div className={`w-5 h-5 rounded ${currentQ.type === 'SATA' ? 'border' : 'border rounded-full'} flex items-center justify-center transition-colors ${
isSelected ? 'border-primary bg-primary text-white' : 'border-border-light bg-bg-surface'
}`}>
{isSelected && (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
)}
</div>
<div className="flex-1 text-text-body font-medium leading-snug">
{opt.text}
</div>
</div>
);
})}
</div>
{/* Rationale shown after submission */}
{isSubmitted && (
<div className="mt-8 bg-bg-callout border border-primary/20 rounded-xl p-5 animate-slide-up">
<h4 className="text-sm font-bold text-text-heading uppercase tracking-wider mb-2">Rationale</h4>
<div className="markdown-content text-sm text-text-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{currentQ.rationale}
</ReactMarkdown>
</div>
</div>
)}
</div>
{/* Action bar */}
<div className="bg-bg-surface-alt px-6 py-5 border-t border-border-light flex justify-end">
{!isSubmitted ? (
<button
onClick={handleSubmit}
disabled={currentSelections.length === 0}
className="px-6 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover disabled:opacity-50 transition-all duration-200 cursor-pointer"
>
Submit Answer
</button>
) : currentIndex + 1 < order.length ? (
<button
onClick={handleNext}
className="px-6 py-2.5 rounded-lg bg-text-heading text-white font-medium hover:bg-text-body transition-all duration-200 cursor-pointer"
>
Next Question
</button>
) : (
<button
onClick={handleFinish}
className="px-6 py-2.5 rounded-lg bg-success text-white font-medium hover:bg-success/90 transition-all duration-200 cursor-pointer"
>
Finish Quiz
</button>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,43 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ClassTabs } from "./ClassTabs";
interface ClassHeaderProps {
classSlug: string;
className: string;
}
export function ClassHeader({ classSlug, className }: ClassHeaderProps) {
const pathname = usePathname();
// If we are deeper than /[classSlug]/[tab], hide this header to save space
// Paths look like ['', 'classSlug', 'flashcards', 'deckId']
// Length 3 means we are on the tab root (e.g. /anatomy/flashcards)
// Length > 3 means we are viewing a specific deck or quiz
const isDetailView = pathname.split("/").filter(Boolean).length > 2;
if (isDetailView) {
return null;
}
return (
<>
<div className="mb-6">
<Link
href="/"
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-text-heading mb-2 transition-colors"
>
<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
</Link>
<h1 className="text-2xl font-bold text-text-heading">{className}</h1>
</div>
<ClassTabs classSlug={classSlug} />
</>
);
}

View file

@ -0,0 +1,41 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { STUDY_MODES } from "@/config/studyModes";
interface ClassTabsProps {
classSlug: string;
}
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">
{STUDY_MODES.map((mode) => {
const href = `/${classSlug}/${mode.path}`;
const isActive = pathname === href || pathname.startsWith(href + "/");
return (
<Link
key={mode.key}
href={href}
className={`relative px-5 py-3 text-sm font-medium transition-colors duration-200 ${
isActive
? "text-primary"
: "text-text-muted hover:text-text-heading"
}`}
>
{mode.label}
{isActive && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full" />
)}
</Link>
);
})}
</nav>
</div>
);
}

View file

@ -0,0 +1,48 @@
"use client";
import { useRouter } from "next/navigation";
import Link from "next/link";
export function Navbar() {
const router = useRouter();
async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" });
router.push("/login");
router.refresh();
}
return (
<nav className="bg-bg-surface border-b border-border-light">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-14">
<Link
href="/"
className="flex items-center gap-2.5 text-text-heading font-semibold hover:text-primary transition-colors"
>
<svg
className="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
Study
</Link>
<button
onClick={handleLogout}
className="text-sm text-text-muted hover:text-text-heading transition-colors cursor-pointer"
>
Sign out
</button>
</div>
</div>
</nav>
);
}

View file

@ -0,0 +1,121 @@
"use client";
import { useState, useEffect } from "react";
interface ShareMenuProps {
targetType: "DECK" | "QUIZ";
contentId: string;
classSlug: string;
}
export function ShareMenu({ targetType, contentId, classSlug }: ShareMenuProps) {
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!open) return;
fetch(`/api/share?targetType=${targetType}&contentId=${contentId}`)
.then(res => res.json())
.then(data => setToken(data.token))
.finally(() => setLoading(false));
}, [open, targetType, contentId]);
async function handleToggle() {
setLoading(true);
try {
const res = await fetch("/api/share", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetType, contentId }),
});
const data = await res.json();
setToken(data.token);
} finally {
setLoading(false);
}
}
async function handleCopy() {
if (!token) return;
const typeStr = targetType === "DECK" ? "flashcards" : "quizzes";
const url = `${window.location.origin}/shared/${classSlug}/${typeStr}/${token}`;
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<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"
title="Share"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</button>
{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>
{loading ? (
<div className="h-8 bg-bg-surface-alt rounded w-full animate-subtle-pulse" />
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-text-secondary">Enable link sharing</span>
<button
onClick={handleToggle}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
token ? 'bg-primary' : 'bg-border'
}`}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
token ? 'translate-x-5' : 'translate-x-1'
}`}
/>
</button>
</div>
{token && (
<div className="space-y-2 pt-2 border-t border-border-light">
<p className="text-xs text-text-muted">
Anyone with the link can view and study this {targetType === "DECK" ? "deck" : "quiz"}. No progress is saved.
</p>
<button
onClick={handleCopy}
className="w-full flex items-center justify-center gap-2 py-2 px-3 rounded-lg border border-primary text-primary hover:bg-primary/5 transition-colors text-sm font-medium cursor-pointer"
>
{copied ? (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Copied to Clipboard
</>
) : (
<>
<svg className="w-4 h-4" 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>
Copy Link
</>
)}
</button>
</div>
)}
</div>
)}
</div>
</>
)}
</div>
);
}

7
src/config/studyModes.ts Normal file
View file

@ -0,0 +1,7 @@
export const STUDY_MODES = [
{ key: "flashcards", label: "Flashcards", path: "flashcards" },
{ key: "quizzes", label: "Quizzes", path: "quizzes" },
// Future modes get added here — the layout, tab bar, and auth middleware need no changes.
] as const;
export type StudyModeKey = (typeof STUDY_MODES)[number]["key"];

View file

@ -0,0 +1,74 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file should be your main import to use Prisma-related types and utilities in a browser.
* Use it to get access to models, enums, and input types.
*
* This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only.
* See `client.ts` for the standard, server-side entry point.
*
* 🟢 You can import this file directly.
*/
import * as Prisma from './internal/prismaNamespaceBrowser'
export { Prisma }
export * as $Enums from './enums'
export * from './enums';
/**
* Model Class
*
*/
export type Class = Prisma.ClassModel
/**
* Model Deck
*
*/
export type Deck = Prisma.DeckModel
/**
* Model Flashcard
*
*/
export type Flashcard = Prisma.FlashcardModel
/**
* Model QuizSet
*
*/
export type QuizSet = Prisma.QuizSetModel
/**
* Model Question
*
*/
export type Question = Prisma.QuestionModel
/**
* Model AnswerOption
*
*/
export type AnswerOption = Prisma.AnswerOptionModel
/**
* Model StudyProgress
*
*/
export type StudyProgress = Prisma.StudyProgressModel
/**
* Model QuizAttempt
*
*/
export type QuizAttempt = Prisma.QuizAttemptModel
/**
* Model ShareLink
*
*/
export type ShareLink = Prisma.ShareLinkModel
/**
* Model AuthSecurity
*
*/
export type AuthSecurity = Prisma.AuthSecurityModel
/**
* Model Setting
*
*/
export type Setting = Prisma.SettingModel

View file

@ -0,0 +1,98 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
* If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead.
*
* 🟢 You can import this file directly.
*/
import * as process from 'node:process'
import * as path from 'node:path'
import { fileURLToPath } from 'node:url'
globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url))
import * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums"
import * as $Class from "./internal/class"
import * as Prisma from "./internal/prismaNamespace"
export * as $Enums from './enums'
export * from "./enums"
/**
* ## Prisma Client
*
* Type-safe database client for TypeScript
* @example
* ```
* const prisma = new PrismaClient({
* adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL })
* })
* // Fetch zero or more Classes
* const classes = await prisma.class.findMany()
* ```
*
* Read more in our [docs](https://pris.ly/d/client).
*/
export const PrismaClient = $Class.getPrismaClientClass()
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
export { Prisma }
/**
* Model Class
*
*/
export type Class = Prisma.ClassModel
/**
* Model Deck
*
*/
export type Deck = Prisma.DeckModel
/**
* Model Flashcard
*
*/
export type Flashcard = Prisma.FlashcardModel
/**
* Model QuizSet
*
*/
export type QuizSet = Prisma.QuizSetModel
/**
* Model Question
*
*/
export type Question = Prisma.QuestionModel
/**
* Model AnswerOption
*
*/
export type AnswerOption = Prisma.AnswerOptionModel
/**
* Model StudyProgress
*
*/
export type StudyProgress = Prisma.StudyProgressModel
/**
* Model QuizAttempt
*
*/
export type QuizAttempt = Prisma.QuizAttemptModel
/**
* Model ShareLink
*
*/
export type ShareLink = Prisma.ShareLinkModel
/**
* Model AuthSecurity
*
*/
export type AuthSecurity = Prisma.AuthSecurityModel
/**
* Model Setting
*
*/
export type Setting = Prisma.SettingModel

View file

@ -0,0 +1,391 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file exports various common sort, input & filter types that are not directly linked to a particular model.
*
* 🟢 You can import this file directly.
*/
import type * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums"
import type * as Prisma from "./internal/prismaNamespace"
export type StringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type IntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type DateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type StringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type IntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type StringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type SortOrderInput = {
sort: Prisma.SortOrder
nulls?: Prisma.NullsOrder
}
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type BoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
export type FloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}
export type FloatWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
_min?: Prisma.NestedFloatFilter<$PrismaModel>
_max?: Prisma.NestedFloatFilter<$PrismaModel>
}
export type DateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type NestedStringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type NestedIntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type NestedDateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type NestedFloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type NestedStringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type NestedIntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
export type NestedBoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
export type NestedFloatWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
_min?: Prisma.NestedFloatFilter<$PrismaModel>
_max?: Prisma.NestedFloatFilter<$PrismaModel>
}
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}

View file

@ -0,0 +1,15 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file exports all enum related types from the schema.
*
* 🟢 You can import this file directly.
*/
// This file is empty because there are no enums in the schema.
export {}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,222 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* WARNING: This is an internal file that is subject to change!
*
* 🛑 Under no circumstances should you import this file directly! 🛑
*
* All exports from this file are wrapped under a `Prisma` namespace object in the browser.ts file.
* While this enables partial backward compatibility, it is not part of the stable public API.
*
* If you are looking for your Models, Enums, and Input Types, please import them from the respective
* model files in the `model` directory!
*/
import * as runtime from "@prisma/client/runtime/index-browser"
export type * from '../models'
export type * from './prismaNamespace'
export const Decimal = runtime.Decimal
export const NullTypes = {
DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
}
/**
* Helper for filtering JSON entries that have `null` on the database (empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const DbNull = runtime.DbNull
/**
* Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const JsonNull = runtime.JsonNull
/**
* Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const AnyNull = runtime.AnyNull
export const ModelName = {
Class: 'Class',
Deck: 'Deck',
Flashcard: 'Flashcard',
QuizSet: 'QuizSet',
Question: 'Question',
AnswerOption: 'AnswerOption',
StudyProgress: 'StudyProgress',
QuizAttempt: 'QuizAttempt',
ShareLink: 'ShareLink',
AuthSecurity: 'AuthSecurity',
Setting: 'Setting'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
/*
* Enums
*/
export const TransactionIsolationLevel = runtime.makeStrictEnum({
Serializable: 'Serializable'
} as const)
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
export const ClassScalarFieldEnum = {
id: 'id',
slug: 'slug',
name: 'name',
sortOrder: 'sortOrder',
createdAt: 'createdAt'
} as const
export type ClassScalarFieldEnum = (typeof ClassScalarFieldEnum)[keyof typeof ClassScalarFieldEnum]
export const DeckScalarFieldEnum = {
id: 'id',
classId: 'classId',
name: 'name',
description: 'description',
sortOrder: 'sortOrder',
createdAt: 'createdAt'
} as const
export type DeckScalarFieldEnum = (typeof DeckScalarFieldEnum)[keyof typeof DeckScalarFieldEnum]
export const FlashcardScalarFieldEnum = {
id: 'id',
deckId: 'deckId',
front: 'front',
back: 'back',
sortOrder: 'sortOrder'
} as const
export type FlashcardScalarFieldEnum = (typeof FlashcardScalarFieldEnum)[keyof typeof FlashcardScalarFieldEnum]
export const QuizSetScalarFieldEnum = {
id: 'id',
classId: 'classId',
name: 'name',
description: 'description',
sortOrder: 'sortOrder',
createdAt: 'createdAt'
} as const
export type QuizSetScalarFieldEnum = (typeof QuizSetScalarFieldEnum)[keyof typeof QuizSetScalarFieldEnum]
export const QuestionScalarFieldEnum = {
id: 'id',
quizSetId: 'quizSetId',
type: 'type',
prompt: 'prompt',
rationale: 'rationale',
category: 'category',
sortOrder: 'sortOrder'
} as const
export type QuestionScalarFieldEnum = (typeof QuestionScalarFieldEnum)[keyof typeof QuestionScalarFieldEnum]
export const AnswerOptionScalarFieldEnum = {
id: 'id',
questionId: 'questionId',
text: 'text',
isCorrect: 'isCorrect',
sortOrder: 'sortOrder'
} as const
export type AnswerOptionScalarFieldEnum = (typeof AnswerOptionScalarFieldEnum)[keyof typeof AnswerOptionScalarFieldEnum]
export const StudyProgressScalarFieldEnum = {
id: 'id',
contentType: 'contentType',
deckId: 'deckId',
quizSetId: 'quizSetId',
mode: 'mode',
currentIndex: 'currentIndex',
orderJson: 'orderJson',
answersJson: 'answersJson',
cardResultsJson: 'cardResultsJson',
updatedAt: 'updatedAt'
} as const
export type StudyProgressScalarFieldEnum = (typeof StudyProgressScalarFieldEnum)[keyof typeof StudyProgressScalarFieldEnum]
export const QuizAttemptScalarFieldEnum = {
id: 'id',
quizSetId: 'quizSetId',
score: 'score',
maxScore: 'maxScore',
answersJson: 'answersJson',
isPartialRetake: 'isPartialRetake',
completedAt: 'completedAt'
} as const
export type QuizAttemptScalarFieldEnum = (typeof QuizAttemptScalarFieldEnum)[keyof typeof QuizAttemptScalarFieldEnum]
export const ShareLinkScalarFieldEnum = {
id: 'id',
targetType: 'targetType',
deckId: 'deckId',
quizSetId: 'quizSetId',
createdAt: 'createdAt'
} as const
export type ShareLinkScalarFieldEnum = (typeof ShareLinkScalarFieldEnum)[keyof typeof ShareLinkScalarFieldEnum]
export const AuthSecurityScalarFieldEnum = {
id: 'id',
failedAttempts: 'failedAttempts',
lockedUntil: 'lockedUntil',
lastAttemptAt: 'lastAttemptAt'
} as const
export type AuthSecurityScalarFieldEnum = (typeof AuthSecurityScalarFieldEnum)[keyof typeof AuthSecurityScalarFieldEnum]
export const SettingScalarFieldEnum = {
key: 'key',
value: 'value'
} as const
export type SettingScalarFieldEnum = (typeof SettingScalarFieldEnum)[keyof typeof SettingScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
} as const
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
export const NullsOrder = {
first: 'first',
last: 'last'
} as const
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]

View file

@ -0,0 +1,22 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This is a barrel export file for all models and their related types.
*
* 🟢 You can import this file directly.
*/
export type * from './models/Class'
export type * from './models/Deck'
export type * from './models/Flashcard'
export type * from './models/QuizSet'
export type * from './models/Question'
export type * from './models/AnswerOption'
export type * from './models/StudyProgress'
export type * from './models/QuizAttempt'
export type * from './models/ShareLink'
export type * from './models/AuthSecurity'
export type * from './models/Setting'
export type * from './commonInputTypes'

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

37
src/lib/auth.ts Normal file
View file

@ -0,0 +1,37 @@
import { getIronSession, type SessionOptions } from "iron-session";
import { cookies } from "next/headers";
export interface SessionData {
isAuthenticated: boolean;
}
const sessionOptions: SessionOptions = {
password: process.env.SESSION_SECRET || "dev-session-secret-change-in-production-must-be-32-chars",
cookieName: "study-app-session",
cookieOptions: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
sameSite: "lax" as const,
},
};
export async function getSession() {
const cookieStore = await cookies();
return getIronSession<SessionData>(cookieStore, sessionOptions);
}
export async function createSession() {
const session = await getSession();
session.isAuthenticated = true;
await session.save();
}
export async function destroySession() {
const session = await getSession();
session.destroy();
}
export async function isAuthenticated() {
const session = await getSession();
return session.isAuthenticated === true;
}

19
src/lib/db.ts Normal file
View file

@ -0,0 +1,19 @@
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
function createPrismaClient() {
const adapter = new PrismaBetterSqlite3({
url: process.env.DATABASE_URL ?? "file:./dev.db",
});
return new PrismaClient({ adapter });
}
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}

51
src/lib/jsonRepair.ts Normal file
View file

@ -0,0 +1,51 @@
import { jsonrepair } from "jsonrepair";
export interface RepairResult {
success: boolean;
data: unknown;
wasRepaired: boolean;
repairedJson?: string;
error?: string;
}
/**
* Parse strip fences repair return.
* Syntax is mechanical and safe to fix automatically.
* Content validation (Zod) runs separately.
*/
export function parseAndRepairJson(input: string): RepairResult {
// Step 1: Strip markdown code fences if present
let text = input.trim();
const fenceMatch = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?\s*```$/);
if (fenceMatch) {
text = fenceMatch[1].trim();
}
// Step 2: Try direct parse
try {
const data = JSON.parse(text);
return { success: true, data, wasRepaired: false };
} catch {
// Step 3: Try jsonrepair
try {
const repaired = jsonrepair(text);
const data = JSON.parse(repaired);
return {
success: true,
data,
wasRepaired: true,
repairedJson: repaired,
};
} catch (repairError) {
return {
success: false,
data: null,
wasRepaired: false,
error:
repairError instanceof Error
? repairError.message
: "Failed to parse or repair JSON",
};
}
}
}

31
src/lib/rateLimiter.ts Normal file
View file

@ -0,0 +1,31 @@
/**
* In-memory sliding window rate limiter.
* Single-instance, single-user container no need for Redis.
*/
interface RateLimitEntry {
timestamps: number[];
}
const store = new Map<string, RateLimitEntry>();
const WINDOW_MS = 60 * 1000; // 1 minute
const MAX_REQUESTS = 10;
export function checkRateLimit(ip: string): { allowed: boolean; retryAfterMs: number } {
const now = Date.now();
const entry = store.get(ip) ?? { timestamps: [] };
// Remove timestamps outside the window
entry.timestamps = entry.timestamps.filter((t) => now - t < WINDOW_MS);
if (entry.timestamps.length >= MAX_REQUESTS) {
const oldestInWindow = entry.timestamps[0];
const retryAfterMs = WINDOW_MS - (now - oldestInWindow);
return { allowed: false, retryAfterMs };
}
entry.timestamps.push(now);
store.set(ip, entry);
return { allowed: true, retryAfterMs: 0 };
}

41
src/lib/scoring.ts Normal file
View file

@ -0,0 +1,41 @@
type OptionLite = { id: string; isCorrect: boolean };
type QuestionLite = {
id: string;
type: "MULTIPLE_CHOICE" | "SATA";
options: OptionLite[];
};
export function scoreQuestion(
question: QuestionLite,
selectedIds: string[]
): number {
const correctIds = question.options
.filter((o) => o.isCorrect)
.map((o) => o.id);
if (question.type === "MULTIPLE_CHOICE") {
return correctIds.includes(selectedIds[0]) ? 1 : 0;
}
// SATA: partial credit
const correctSelected = selectedIds.filter((id) =>
correctIds.includes(id)
).length;
const incorrectSelected = selectedIds.filter(
(id) => !correctIds.includes(id)
).length;
return Math.max(0, correctSelected - incorrectSelected) / correctIds.length;
}
export function scoreQuiz(
questions: QuestionLite[],
answers: Record<string, string[]>
) {
let total = 0;
const perQuestion = questions.map((q) => {
const points = scoreQuestion(q, answers[q.id] ?? []);
total += points;
return { questionId: q.id, points };
});
return { total, maxScore: questions.length, perQuestion };
}

35
src/lib/shuffle.ts Normal file
View file

@ -0,0 +1,35 @@
/**
* Fisher-Yates shuffle produces a random permutation.
* Returns a new array, does not mutate the input.
*/
export function shuffle<T>(array: T[]): T[] {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
/**
* Generate a sequential order array [0, 1, 2, ..., n-1]
* mapped to the provided ids.
*/
export function generateOrder(ids: string[], mode: "SEQUENTIAL" | "SHUFFLED"): string[] {
return mode === "SHUFFLED" ? shuffle(ids) : [...ids];
}
/**
* Filter an order array to only include ids that still exist,
* and clamp the current index if it overflows.
* Handles the case where cards/questions are deleted mid-session.
*/
export function filterAndClampOrder(
orderJson: string[],
existingIds: Set<string>,
currentIndex: number
): { order: string[]; index: number } {
const filtered = orderJson.filter((id) => existingIds.has(id));
const clampedIndex = Math.min(currentIndex, Math.max(0, filtered.length - 1));
return { order: filtered, index: clampedIndex };
}

View file

@ -0,0 +1,48 @@
import { z } from "zod";
const optionSchema = z.object({
text: z.string().min(1),
correct: z.boolean(),
});
const questionSchema = z
.object({
type: z.enum(["multiple_choice", "sata"]),
prompt: z.string().min(1),
rationale: z.string().min(1),
category: z.string().min(1),
options: z.array(optionSchema).min(2),
})
.refine((q) => q.options.filter((o) => o.correct).length >= 1, {
message: "Each question needs at least one correct option",
})
.refine(
(q) =>
q.type !== "multiple_choice" ||
q.options.filter((o) => o.correct).length === 1,
{ message: "multiple_choice questions must have exactly one correct option" }
);
export const quizImportSchema = z.object({
type: z.literal("quiz"),
quizName: z.string().min(1),
description: z.string().optional(),
questions: z.array(questionSchema).min(1),
});
export const flashcardImportSchema = z.object({
type: z.literal("flashcards"),
deckName: z.string().min(1),
description: z.string().optional(),
cards: z
.array(
z.object({
front: z.string().min(1),
back: z.string().min(1),
})
)
.min(1),
});
export type QuizImportData = z.infer<typeof quizImportSchema>;
export type FlashcardImportData = z.infer<typeof flashcardImportSchema>;

49
src/middleware.ts Normal file
View file

@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { getIronSession } from "iron-session";
import type { SessionData } from "@/lib/auth";
const sessionOptions = {
password:
process.env.SESSION_SECRET ||
"dev-session-secret-change-in-production-must-be-32-chars",
cookieName: "study-app-session",
};
// Routes that don't require authentication
const publicPaths = ["/login", "/api/auth", "/shared"];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Allow public routes
if (publicPaths.some((path) => pathname.startsWith(path))) {
return NextResponse.next();
}
// Allow static assets and Next.js internals
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/favicon") ||
pathname.includes(".")
) {
return NextResponse.next();
}
// Check session
const response = NextResponse.next();
const session = await getIronSession<SessionData>(
request,
response,
sessionOptions
);
if (!session.isAuthenticated) {
return NextResponse.redirect(new URL("/login", request.url));
}
return response;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

View file

@ -0,0 +1,39 @@
import { prisma } from "@/lib/db";
export async function createCard(
deckId: string,
data: { front: string; back: string }
) {
const maxOrder = await prisma.flashcard.aggregate({
where: { deckId },
_max: { sortOrder: true },
});
const sortOrder = (maxOrder._max.sortOrder ?? -1) + 1;
return prisma.flashcard.create({
data: {
deckId,
front: data.front,
back: data.back,
sortOrder,
},
});
}
export async function updateCard(
id: string,
data: { front?: string; back?: string }
) {
return prisma.flashcard.update({
where: { id },
data,
});
}
export async function deleteCard(id: string) {
// The card is deleted; StudyProgress.orderJson may reference this id.
// On session resume, filterAndClampOrder() handles stale ids (see shuffle.ts).
return prisma.flashcard.delete({
where: { id },
});
}

View file

@ -0,0 +1,62 @@
import { prisma } from "@/lib/db";
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
}
export async function listClasses() {
return prisma.class.findMany({
orderBy: { sortOrder: "asc" },
include: {
_count: {
select: { decks: true, quizSets: true },
},
},
});
}
export async function getClassBySlug(slug: string) {
return prisma.class.findUnique({
where: { slug },
include: {
_count: {
select: { decks: true, quizSets: true },
},
},
});
}
export async function createClass(name: string) {
const baseSlug = slugify(name);
let slug = baseSlug;
let counter = 1;
// Ensure unique slug
while (await prisma.class.findUnique({ where: { slug } })) {
slug = `${baseSlug}-${counter}`;
counter++;
}
const maxOrder = await prisma.class.aggregate({ _max: { sortOrder: true } });
const sortOrder = (maxOrder._max.sortOrder ?? -1) + 1;
return prisma.class.create({
data: { name, slug, sortOrder },
});
}
export async function updateClass(id: string, data: { name?: string }) {
return prisma.class.update({
where: { id },
data,
});
}
export async function deleteClass(id: string) {
return prisma.class.delete({
where: { id },
});
}

View file

@ -0,0 +1,79 @@
import { prisma } from "@/lib/db";
import type { FlashcardImportData } from "@/lib/validation/importSchemas";
export async function listDecksByClass(classId: string) {
return prisma.deck.findMany({
where: { classId },
orderBy: { sortOrder: "asc" },
include: {
_count: { select: { cards: true } },
progress: {
select: {
mode: true,
currentIndex: true,
orderJson: true,
cardResultsJson: true,
},
},
},
});
}
export async function getDeckWithCards(deckId: string) {
return prisma.deck.findUnique({
where: { id: deckId },
include: {
cards: { orderBy: { sortOrder: "asc" } },
class: { select: { slug: true, name: true } },
progress: true,
},
});
}
export async function createDeckFromImport(
classId: string,
data: FlashcardImportData,
overrideName?: string
) {
const maxOrder = await prisma.deck.aggregate({
where: { classId },
_max: { sortOrder: true },
});
const sortOrder = (maxOrder._max.sortOrder ?? -1) + 1;
return prisma.deck.create({
data: {
classId,
name: overrideName || data.deckName,
description: data.description,
sortOrder,
cards: {
create: data.cards.map((card, index) => ({
front: card.front,
back: card.back,
sortOrder: index,
})),
},
},
include: {
cards: true,
_count: { select: { cards: true } },
},
});
}
export async function updateDeck(
id: string,
data: { name?: string; description?: string }
) {
return prisma.deck.update({
where: { id },
data,
});
}
export async function deleteDeck(id: string) {
return prisma.deck.delete({
where: { id },
});
}

View file

@ -0,0 +1,85 @@
import { prisma } from "@/lib/db";
export async function getProgress(
contentType: "DECK" | "QUIZ",
contentId: string,
mode: "SEQUENTIAL" | "SHUFFLED"
) {
if (contentType === "DECK") {
return prisma.studyProgress.findUnique({
where: { deckId_mode: { deckId: contentId, mode } },
});
}
return prisma.studyProgress.findUnique({
where: { quizSetId_mode: { quizSetId: contentId, mode } },
});
}
export async function upsertProgress(data: {
contentType: "DECK" | "QUIZ";
contentId: string;
mode: "SEQUENTIAL" | "SHUFFLED";
currentIndex: number;
orderJson: string;
answersJson?: string;
cardResultsJson?: string;
}) {
const base = {
contentType: data.contentType,
mode: data.mode,
currentIndex: data.currentIndex,
orderJson: data.orderJson,
answersJson: data.answersJson ?? null,
cardResultsJson: data.cardResultsJson ?? null,
};
if (data.contentType === "DECK") {
return prisma.studyProgress.upsert({
where: { deckId_mode: { deckId: data.contentId, mode: data.mode } },
update: {
currentIndex: data.currentIndex,
orderJson: data.orderJson,
cardResultsJson: data.cardResultsJson ?? null,
},
create: {
...base,
deckId: data.contentId,
},
});
}
return prisma.studyProgress.upsert({
where: {
quizSetId_mode: { quizSetId: data.contentId, mode: data.mode },
},
update: {
currentIndex: data.currentIndex,
orderJson: data.orderJson,
answersJson: data.answersJson ?? null,
},
create: {
...base,
quizSetId: data.contentId,
},
});
}
export async function clearProgress(
contentType: "DECK" | "QUIZ",
contentId: string,
mode: "SEQUENTIAL" | "SHUFFLED"
) {
try {
if (contentType === "DECK") {
await prisma.studyProgress.delete({
where: { deckId_mode: { deckId: contentId, mode } },
});
} else {
await prisma.studyProgress.delete({
where: { quizSetId_mode: { quizSetId: contentId, mode } },
});
}
} catch {
// Row may not exist — that's fine
}
}

110
src/services/quizService.ts Normal file
View file

@ -0,0 +1,110 @@
import { prisma } from "@/lib/db";
import type { QuizImportData } from "@/lib/validation/importSchemas";
export async function listQuizSetsByClass(classId: string) {
return prisma.quizSet.findMany({
where: { classId },
orderBy: { sortOrder: "asc" },
include: {
_count: { select: { questions: true, attempts: true } },
progress: {
select: {
mode: true,
currentIndex: true,
orderJson: true,
answersJson: true,
},
},
},
});
}
export async function getQuizSetWithQuestions(quizSetId: string) {
return prisma.quizSet.findUnique({
where: { id: quizSetId },
include: {
questions: {
orderBy: { sortOrder: "asc" },
include: {
options: { orderBy: { sortOrder: "asc" } },
},
},
class: { select: { slug: true, name: true } },
progress: true,
},
});
}
export async function createQuizSetFromImport(
classId: string,
data: QuizImportData,
overrideName?: string
) {
const maxOrder = await prisma.quizSet.aggregate({
where: { classId },
_max: { sortOrder: true },
});
const sortOrder = (maxOrder._max.sortOrder ?? -1) + 1;
return prisma.quizSet.create({
data: {
classId,
name: overrideName || data.quizName,
description: data.description,
sortOrder,
questions: {
create: data.questions.map((q, qi) => ({
type: q.type === "multiple_choice" ? "MULTIPLE_CHOICE" : "SATA",
prompt: q.prompt,
rationale: q.rationale,
category: q.category.trim().toLowerCase(),
sortOrder: qi,
options: {
create: q.options.map((opt, oi) => ({
text: opt.text,
isCorrect: opt.correct,
sortOrder: oi,
})),
},
})),
},
},
include: {
questions: { include: { options: true } },
_count: { select: { questions: true } },
},
});
}
export async function updateQuizSet(
id: string,
data: { name?: string; description?: string }
) {
return prisma.quizSet.update({
where: { id },
data,
});
}
export async function deleteQuizSet(id: string) {
return prisma.quizSet.delete({
where: { id },
});
}
export async function createQuizAttempt(data: {
quizSetId: string;
score: number;
maxScore: number;
answersJson: string;
isPartialRetake?: boolean;
}) {
return prisma.quizAttempt.create({ data });
}
export async function listQuizAttempts(quizSetId: string) {
return prisma.quizAttempt.findMany({
where: { quizSetId },
orderBy: { completedAt: "desc" },
});
}

Some files were not shown because too many files have changed in this diff Show more