diff --git a/app/(app)/quizzes/page.tsx b/app/(app)/quizzes/page.tsx index 2b6c57c..1ead836 100644 --- a/app/(app)/quizzes/page.tsx +++ b/app/(app)/quizzes/page.tsx @@ -1,15 +1,17 @@ import { connection } from "next/server"; import { desc, eq } from "drizzle-orm"; import { db } from "@/lib/db"; -import { note } from "@/lib/db/schema"; +import { note, quizAttempts, quizzes } from "@/lib/db/schema"; import { requireServerSession } from "@/lib/auth-session"; import { QuizzesClient } from "@/app/quizzes/quizzes-client"; +import { rowToQuizAttempt, rowToSavedQuiz } from "@/lib/quizzes/records"; +import { hasQuizStorage } from "@/lib/quizzes/storage"; export default async function QuizzesPage() { await connection(); const session = await requireServerSession("/quizzes"); - const rows = await db + const noteRows = await db .select({ id: note.id, title: note.title, @@ -20,14 +22,35 @@ export default async function QuizzesPage() { .where(eq(note.userId, session.user.id)) .orderBy(desc(note.updatedAt)); + const quizStorageReady = await hasQuizStorage(); + const { quizRows, attemptRows } = quizStorageReady + ? await Promise.all([ + db + .select() + .from(quizzes) + .where(eq(quizzes.userId, session.user.id)) + .orderBy(desc(quizzes.updatedAt)), + db + .select() + .from(quizAttempts) + .where(eq(quizAttempts.userId, session.user.id)) + .orderBy(desc(quizAttempts.completedAt)), + ]).then(([loadedQuizRows, loadedAttemptRows]) => ({ + quizRows: loadedQuizRows, + attemptRows: loadedAttemptRows, + })) + : { quizRows: [], attemptRows: [] }; + return ( ({ + notes={noteRows.map((row) => ({ id: row.id, title: row.title, updatedAt: row.updatedAt.toISOString(), hasEmbedding: Array.isArray(row.embedding) && row.embedding.length > 0, }))} + initialQuizzes={quizRows.map(rowToSavedQuiz)} + initialAttempts={attemptRows.map(rowToQuizAttempt)} /> ); } diff --git a/app/api/quizzes/[id]/attempts/route.ts b/app/api/quizzes/[id]/attempts/route.ts new file mode 100644 index 0000000..bbc20ac --- /dev/null +++ b/app/api/quizzes/[id]/attempts/route.ts @@ -0,0 +1,91 @@ +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { and, desc, eq } from "drizzle-orm"; +import { auth } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { quizAttempts, quizzes } from "@/lib/db/schema"; +import { rowToQuizAttempt, saveAttemptSchema } from "@/lib/quizzes/records"; +import { hasQuizStorage, quizStorageUnavailableMessage } from "@/lib/quizzes/storage"; + +export const runtime = "nodejs"; + +type RouteContext = { params: Promise<{ id: string }> }; + +async function getOwnedQuiz(userId: string, quizId: string) { + const [found] = await db + .select({ id: quizzes.id }) + .from(quizzes) + .where(and(eq(quizzes.id, quizId), eq(quizzes.userId, userId))) + .limit(1); + + return found ?? null; +} + +export async function GET(_req: Request, ctx: RouteContext) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (!(await hasQuizStorage())) { + return NextResponse.json({ attempts: [] }); + } + + const { id } = await ctx.params; + if (!(await getOwnedQuiz(session.user.id, id))) { + return NextResponse.json({ error: "Quiz not found" }, { status: 404 }); + } + + const rows = await db + .select() + .from(quizAttempts) + .where(and(eq(quizAttempts.quizId, id), eq(quizAttempts.userId, session.user.id))) + .orderBy(desc(quizAttempts.completedAt)); + + return NextResponse.json({ attempts: rows.map(rowToQuizAttempt) }); +} + +export async function POST(req: Request, ctx: RouteContext) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (!(await hasQuizStorage())) { + return NextResponse.json({ error: quizStorageUnavailableMessage }, { status: 503 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = saveAttemptSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid attempt payload" }, { status: 400 }); + } + + const { id } = await ctx.params; + if (!(await getOwnedQuiz(session.user.id, id))) { + return NextResponse.json({ error: "Quiz not found" }, { status: 404 }); + } + + const [created] = await db + .insert(quizAttempts) + .values({ + quizId: id, + userId: session.user.id, + answers: parsed.data.answers, + score: parsed.data.score, + correctCount: parsed.data.correctCount, + answeredCount: parsed.data.answeredCount, + questionCount: parsed.data.questionCount, + timeSpentSeconds: parsed.data.timeSpentSeconds, + mode: parsed.data.mode, + }) + .returning(); + + return NextResponse.json({ attempt: rowToQuizAttempt(created) }, { status: 201 }); +} diff --git a/app/api/quizzes/[id]/route.ts b/app/api/quizzes/[id]/route.ts new file mode 100644 index 0000000..a2969a4 --- /dev/null +++ b/app/api/quizzes/[id]/route.ts @@ -0,0 +1,82 @@ +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { and, eq } from "drizzle-orm"; +import { auth } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { quizzes } from "@/lib/db/schema"; +import { rowToSavedQuiz, saveQuizSchema } from "@/lib/quizzes/records"; +import { hasQuizStorage, quizStorageUnavailableMessage } from "@/lib/quizzes/storage"; + +export const runtime = "nodejs"; + +type RouteContext = { params: Promise<{ id: string }> }; + +export async function PATCH(req: Request, ctx: RouteContext) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (!(await hasQuizStorage())) { + return NextResponse.json({ error: quizStorageUnavailableMessage }, { status: 503 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = saveQuizSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid quiz payload" }, { status: 400 }); + } + + const { id } = await ctx.params; + const [updated] = await db + .update(quizzes) + .set({ + title: parsed.data.title.trim(), + sourceNoteIds: Array.from(new Set(parsed.data.sourceNoteIds)), + sourceNoteTitles: Array.from(new Set(parsed.data.sourceNoteTitles)), + questions: parsed.data.questions, + questionCount: parsed.data.questions.length, + difficulty: parsed.data.difficulty, + mode: parsed.data.mode, + questionTypes: parsed.data.questionTypes, + timeLimitMinutes: parsed.data.mode === "exam" ? parsed.data.timeLimitMinutes : null, + updatedAt: new Date(), + }) + .where(and(eq(quizzes.id, id), eq(quizzes.userId, session.user.id))) + .returning(); + + if (!updated) { + return NextResponse.json({ error: "Quiz not found" }, { status: 404 }); + } + + return NextResponse.json({ quiz: rowToSavedQuiz(updated) }); +} + +export async function DELETE(_req: Request, ctx: RouteContext) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (!(await hasQuizStorage())) { + return NextResponse.json({ error: quizStorageUnavailableMessage }, { status: 503 }); + } + + const { id } = await ctx.params; + const [deleted] = await db + .delete(quizzes) + .where(and(eq(quizzes.id, id), eq(quizzes.userId, session.user.id))) + .returning(); + + if (!deleted) { + return NextResponse.json({ error: "Quiz not found" }, { status: 404 }); + } + + return NextResponse.json({ success: true }); +} diff --git a/app/api/quizzes/route.ts b/app/api/quizzes/route.ts new file mode 100644 index 0000000..bd40993 --- /dev/null +++ b/app/api/quizzes/route.ts @@ -0,0 +1,70 @@ +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { desc, eq } from "drizzle-orm"; +import { auth } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { quizzes } from "@/lib/db/schema"; +import { rowToSavedQuiz, saveQuizSchema } from "@/lib/quizzes/records"; +import { hasQuizStorage, quizStorageUnavailableMessage } from "@/lib/quizzes/storage"; + +export const runtime = "nodejs"; + +export async function GET() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (!(await hasQuizStorage())) { + return NextResponse.json({ quizzes: [] }); + } + + const rows = await db + .select() + .from(quizzes) + .where(eq(quizzes.userId, session.user.id)) + .orderBy(desc(quizzes.updatedAt)); + + return NextResponse.json({ quizzes: rows.map(rowToSavedQuiz) }); +} + +export async function POST(req: Request) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (!(await hasQuizStorage())) { + return NextResponse.json({ error: quizStorageUnavailableMessage }, { status: 503 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = saveQuizSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid quiz payload" }, { status: 400 }); + } + + const [created] = await db + .insert(quizzes) + .values({ + userId: session.user.id, + title: parsed.data.title.trim(), + sourceNoteIds: Array.from(new Set(parsed.data.sourceNoteIds)), + sourceNoteTitles: Array.from(new Set(parsed.data.sourceNoteTitles)), + questions: parsed.data.questions, + questionCount: parsed.data.questions.length, + difficulty: parsed.data.difficulty, + mode: parsed.data.mode, + questionTypes: parsed.data.questionTypes, + timeLimitMinutes: parsed.data.mode === "exam" ? parsed.data.timeLimitMinutes : null, + }) + .returning(); + + return NextResponse.json({ quiz: rowToSavedQuiz(created) }, { status: 201 }); +} diff --git a/app/layout.tsx b/app/layout.tsx index c907352..8d787d0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,11 +1,10 @@ import type { Metadata } from "next"; -import Script from "next/script"; import { Geist, Geist_Mono } from "next/font/google"; import { Toaster } from "sonner"; import "./globals.css"; import "mathlive/fonts.css"; import "mathlive/static.css"; -import { THEME_STORAGE_KEY } from "@/lib/theme"; +import { ThemeInitializer } from "@/components/ui/theme-initializer"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -22,22 +21,6 @@ export const metadata: Metadata = { description: "TaskMaster academic workspace", }; -const themeInitScript = ` -(() => { - try { - var saved = window.localStorage.getItem(${JSON.stringify(THEME_STORAGE_KEY)}); - var theme = saved === "light" || saved === "dark" || saved === "system" ? saved : "system"; - var resolved = theme === "system" - ? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") - : theme; - var root = document.documentElement; - root.classList.toggle("dark", resolved === "dark"); - root.classList.toggle("light", resolved === "light"); - root.dataset.theme = theme; - } catch (_) {} -})(); -`; - export default function RootLayout({ children, }: Readonly<{ @@ -50,11 +33,7 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} > -