Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions app/(app)/quizzes/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 (
<QuizzesClient
notes={rows.map((row) => ({
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)}
/>
);
}
91 changes: 91 additions & 0 deletions app/api/quizzes/[id]/attempts/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
82 changes: 82 additions & 0 deletions app/api/quizzes/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
70 changes: 70 additions & 0 deletions app/api/quizzes/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
25 changes: 2 additions & 23 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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<{
Expand All @@ -50,11 +33,7 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full bg-background text-foreground">
<Script
id="taskmaster-theme-init"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{ __html: themeInitScript }}
/>
<ThemeInitializer />
{children}
<Toaster
position="bottom-right"
Expand Down
Loading
Loading