Initial working commit
Some checks failed
Automated Container Build / build-and-push (push) Failing after 7s
Some checks failed
Automated Container Build / build-and-push (push) Failing after 7s
This commit is contained in:
parent
666ceb7325
commit
b7ce314f01
105 changed files with 35510 additions and 11 deletions
50
.gitignore
vendored
50
.gitignore
vendored
|
|
@ -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
5
AGENTS.md
Normal 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
1
CLAUDE.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
@AGENTS.md
|
||||
29
Dockerfile
Normal file
29
Dockerfile
Normal 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
36
README.md
Normal 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
24
SKILL.md
Normal 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
BIN
dev.db
Normal file
Binary file not shown.
12
docker-compose.override.yml
Normal file
12
docker-compose.override.yml
Normal 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
14
docker-compose.yml
Normal 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
4
docker-entrypoint.sh
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
npx prisma migrate deploy
|
||||
exec node server.js
|
||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal 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
8
next.config.ts
Normal 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
9777
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
36
package.json
Normal file
36
package.json
Normal 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
7
postcss.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
9
prisma.config.ts
Normal file
9
prisma.config.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
130
prisma/migrations/20260628011409_init/migration.sql
Normal file
130
prisma/migrations/20260628011409_init/migration.sql
Normal 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");
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
135
prisma/schema.prisma
Normal 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
1
public/file.svg
Normal 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
1
public/globe.svg
Normal 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
1
public/next.svg
Normal 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
1
public/vercel.svg
Normal 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
1
public/window.svg
Normal 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 |
173
src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx
Normal file
173
src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
253
src/app/(protected)/[classSlug]/flashcards/page.tsx
Normal file
253
src/app/(protected)/[classSlug]/flashcards/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/app/(protected)/[classSlug]/layout.tsx
Normal file
22
src/app/(protected)/[classSlug]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
src/app/(protected)/[classSlug]/page.tsx
Normal file
6
src/app/(protected)/[classSlug]/page.tsx
Normal 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`);
|
||||
}
|
||||
177
src/app/(protected)/[classSlug]/quizzes/[quizId]/page.tsx
Normal file
177
src/app/(protected)/[classSlug]/quizzes/[quizId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
174
src/app/(protected)/[classSlug]/quizzes/page.tsx
Normal file
174
src/app/(protected)/[classSlug]/quizzes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/app/(protected)/layout.tsx
Normal file
21
src/app/(protected)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
208
src/app/(protected)/page.tsx
Normal file
208
src/app/(protected)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
src/app/api/auth/login/route.ts
Normal file
105
src/app/api/auth/login/route.ts
Normal 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 });
|
||||
}
|
||||
7
src/app/api/auth/logout/route.ts
Normal file
7
src/app/api/auth/logout/route.ts
Normal 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 });
|
||||
}
|
||||
34
src/app/api/cards/[id]/route.ts
Normal file
34
src/app/api/cards/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
37
src/app/api/classes/[id]/route.ts
Normal file
37
src/app/api/classes/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
20
src/app/api/classes/route.ts
Normal file
20
src/app/api/classes/route.ts
Normal 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 });
|
||||
}
|
||||
29
src/app/api/decks/[id]/cards/route.ts
Normal file
29
src/app/api/decks/[id]/cards/route.ts
Normal 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 });
|
||||
}
|
||||
48
src/app/api/decks/[id]/route.ts
Normal file
48
src/app/api/decks/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
14
src/app/api/decks/list/route.ts
Normal file
14
src/app/api/decks/list/route.ts
Normal 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);
|
||||
}
|
||||
34
src/app/api/decks/route.ts
Normal file
34
src/app/api/decks/route.ts
Normal 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 });
|
||||
}
|
||||
66
src/app/api/progress/route.ts
Normal file
66
src/app/api/progress/route.ts
Normal 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 });
|
||||
}
|
||||
72
src/app/api/quizzes/[id]/attempt/route.ts
Normal file
72
src/app/api/quizzes/[id]/attempt/route.ts
Normal 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);
|
||||
}
|
||||
48
src/app/api/quizzes/[id]/route.ts
Normal file
48
src/app/api/quizzes/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
14
src/app/api/quizzes/list/route.ts
Normal file
14
src/app/api/quizzes/list/route.ts
Normal 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);
|
||||
}
|
||||
34
src/app/api/quizzes/route.ts
Normal file
34
src/app/api/quizzes/route.ts
Normal 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 });
|
||||
}
|
||||
31
src/app/api/settings/llm-instructions/route.ts
Normal file
31
src/app/api/settings/llm-instructions/route.ts
Normal 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 });
|
||||
}
|
||||
32
src/app/api/share/route.ts
Normal file
32
src/app/api/share/route.ts
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
141
src/app/globals.css
Normal file
141
src/app/globals.css
Normal 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
28
src/app/layout.tsx
Normal 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
143
src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
src/app/shared/[classSlug]/[type]/[token]/page.tsx
Normal file
101
src/app/shared/[classSlug]/[type]/[token]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
243
src/components/flashcards/CardManager.tsx
Normal file
243
src/components/flashcards/CardManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
449
src/components/flashcards/FlashcardViewer.tsx
Normal file
449
src/components/flashcards/FlashcardViewer.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
187
src/components/import/CreateTab.tsx
Normal file
187
src/components/import/CreateTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
src/components/import/GenerateTab.tsx
Normal file
108
src/components/import/GenerateTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/components/import/ImportModal.tsx
Normal file
112
src/components/import/ImportModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
223
src/components/import/ImportTab.tsx
Normal file
223
src/components/import/ImportTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
src/components/quizzes/AttemptHistory.tsx
Normal file
61
src/components/quizzes/AttemptHistory.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/components/quizzes/CategoryBreakdown.tsx
Normal file
67
src/components/quizzes/CategoryBreakdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
src/components/quizzes/QuizResults.tsx
Normal file
151
src/components/quizzes/QuizResults.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
399
src/components/quizzes/QuizViewer.tsx
Normal file
399
src/components/quizzes/QuizViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/components/ui/ClassHeader.tsx
Normal file
43
src/components/ui/ClassHeader.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
41
src/components/ui/ClassTabs.tsx
Normal file
41
src/components/ui/ClassTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/components/ui/Navbar.tsx
Normal file
48
src/components/ui/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
src/components/ui/ShareMenu.tsx
Normal file
121
src/components/ui/ShareMenu.tsx
Normal 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
7
src/config/studyModes.ts
Normal 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"];
|
||||
74
src/generated/prisma/browser.ts
Normal file
74
src/generated/prisma/browser.ts
Normal 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
|
||||
98
src/generated/prisma/client.ts
Normal file
98
src/generated/prisma/client.ts
Normal 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
|
||||
391
src/generated/prisma/commonInputTypes.ts
Normal file
391
src/generated/prisma/commonInputTypes.ts
Normal 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>
|
||||
}
|
||||
|
||||
|
||||
15
src/generated/prisma/enums.ts
Normal file
15
src/generated/prisma/enums.ts
Normal 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 {}
|
||||
304
src/generated/prisma/internal/class.ts
Normal file
304
src/generated/prisma/internal/class.ts
Normal file
File diff suppressed because one or more lines are too long
1634
src/generated/prisma/internal/prismaNamespace.ts
Normal file
1634
src/generated/prisma/internal/prismaNamespace.ts
Normal file
File diff suppressed because it is too large
Load diff
222
src/generated/prisma/internal/prismaNamespaceBrowser.ts
Normal file
222
src/generated/prisma/internal/prismaNamespaceBrowser.ts
Normal 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]
|
||||
|
||||
22
src/generated/prisma/models.ts
Normal file
22
src/generated/prisma/models.ts
Normal 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'
|
||||
1390
src/generated/prisma/models/AnswerOption.ts
Normal file
1390
src/generated/prisma/models/AnswerOption.ts
Normal file
File diff suppressed because it is too large
Load diff
1173
src/generated/prisma/models/AuthSecurity.ts
Normal file
1173
src/generated/prisma/models/AuthSecurity.ts
Normal file
File diff suppressed because it is too large
Load diff
1499
src/generated/prisma/models/Class.ts
Normal file
1499
src/generated/prisma/models/Class.ts
Normal file
File diff suppressed because it is too large
Load diff
1813
src/generated/prisma/models/Deck.ts
Normal file
1813
src/generated/prisma/models/Deck.ts
Normal file
File diff suppressed because it is too large
Load diff
1386
src/generated/prisma/models/Flashcard.ts
Normal file
1386
src/generated/prisma/models/Flashcard.ts
Normal file
File diff suppressed because it is too large
Load diff
1601
src/generated/prisma/models/Question.ts
Normal file
1601
src/generated/prisma/models/Question.ts
Normal file
File diff suppressed because it is too large
Load diff
1470
src/generated/prisma/models/QuizAttempt.ts
Normal file
1470
src/generated/prisma/models/QuizAttempt.ts
Normal file
File diff suppressed because it is too large
Load diff
1947
src/generated/prisma/models/QuizSet.ts
Normal file
1947
src/generated/prisma/models/QuizSet.ts
Normal file
File diff suppressed because it is too large
Load diff
1063
src/generated/prisma/models/Setting.ts
Normal file
1063
src/generated/prisma/models/Setting.ts
Normal file
File diff suppressed because it is too large
Load diff
1417
src/generated/prisma/models/ShareLink.ts
Normal file
1417
src/generated/prisma/models/ShareLink.ts
Normal file
File diff suppressed because it is too large
Load diff
1760
src/generated/prisma/models/StudyProgress.ts
Normal file
1760
src/generated/prisma/models/StudyProgress.ts
Normal file
File diff suppressed because it is too large
Load diff
37
src/lib/auth.ts
Normal file
37
src/lib/auth.ts
Normal 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
19
src/lib/db.ts
Normal 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
51
src/lib/jsonRepair.ts
Normal 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
31
src/lib/rateLimiter.ts
Normal 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
41
src/lib/scoring.ts
Normal 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
35
src/lib/shuffle.ts
Normal 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 };
|
||||
}
|
||||
48
src/lib/validation/importSchemas.ts
Normal file
48
src/lib/validation/importSchemas.ts
Normal 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
49
src/middleware.ts
Normal 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).*)"],
|
||||
};
|
||||
39
src/services/cardService.ts
Normal file
39
src/services/cardService.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
62
src/services/classService.ts
Normal file
62
src/services/classService.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
79
src/services/deckService.ts
Normal file
79
src/services/deckService.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
85
src/services/progressService.ts
Normal file
85
src/services/progressService.ts
Normal 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
110
src/services/quizService.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue