From ce3552cd699c33d3d1a8c7c8be761c3186b27cee Mon Sep 17 00:00:00 2001
From: Mustansir Rangwala <119410651+mustansirr@users.noreply.github.com>
Date: Sun, 28 Jun 2026 19:14:36 +0530
Subject: [PATCH 1/3] feat(quizzes): add difficulty levels and retake feature
---
backend/app/api/routes/quizzes.py | 222 +++++++++++
backend/app/main.py | 3 +-
backend/app/models/quiz.py | 37 ++
frontend/app/dashboard/page.tsx | 21 +-
frontend/app/quizzes/[quizId]/page.tsx | 358 ++++++++++++++++++
frontend/app/quizzes/page.tsx | 175 +++++++++
frontend/components/dashboard/Sidebar.tsx | 16 +-
.../components/quizzes/CreateQuizModal.tsx | 131 +++++++
frontend/components/ui/label.tsx | 24 ++
frontend/components/ui/select.tsx | 190 ++++++++++
frontend/lib/api.ts | 86 +++++
frontend/package.json | 2 +
frontend/pnpm-lock.yaml | 22 ++
.../20260122000000_create_quizzes.sql | 20 +
.../20260123000000_add_quiz_difficulty.sql | 1 +
15 files changed, 1303 insertions(+), 5 deletions(-)
create mode 100644 backend/app/api/routes/quizzes.py
create mode 100644 backend/app/models/quiz.py
create mode 100644 frontend/app/quizzes/[quizId]/page.tsx
create mode 100644 frontend/app/quizzes/page.tsx
create mode 100644 frontend/components/quizzes/CreateQuizModal.tsx
create mode 100644 frontend/components/ui/label.tsx
create mode 100644 frontend/components/ui/select.tsx
create mode 100644 supabase/migrations/20260122000000_create_quizzes.sql
create mode 100644 supabase/migrations/20260123000000_add_quiz_difficulty.sql
diff --git a/backend/app/api/routes/quizzes.py b/backend/app/api/routes/quizzes.py
new file mode 100644
index 0000000..9971fb7
--- /dev/null
+++ b/backend/app/api/routes/quizzes.py
@@ -0,0 +1,222 @@
+import json
+from typing import List, Optional
+from fastapi import APIRouter, HTTPException, Query, status
+from pydantic import BaseModel
+from datetime import datetime
+
+from app.models.quiz import (
+ QuizResponse,
+ GenerateQuizRequest,
+ SubmitQuizRequest,
+ QuizQuestionResponse,
+ QuizCreate,
+ QuizQuestionCreate
+)
+from app.services.supabase_client import get_supabase_client
+from app.services.llm_factory import create_llm
+from app.api.routes.videos import validate_uuid
+
+router = APIRouter(prefix="/quizzes", tags=["quizzes"])
+
+@router.get("", response_model=List[QuizResponse])
+async def list_quizzes(user_id: str = Query(..., description="UUID of the user")):
+ user_uuid = validate_uuid(user_id, "user_id")
+ client = get_supabase_client()
+
+ result = (
+ client.table("quizzes")
+ .select("*")
+ .eq("user_id", str(user_uuid))
+ .order("created_at", desc=True)
+ .execute()
+ )
+ return result.data
+
+@router.get("/{quiz_id}", response_model=QuizResponse)
+async def get_quiz(quiz_id: str, user_id: str = Query(..., description="UUID of the user")):
+ quiz_uuid = validate_uuid(quiz_id, "quiz_id")
+ user_uuid = validate_uuid(user_id, "user_id")
+ client = get_supabase_client()
+
+ # Get quiz
+ quiz_res = client.table("quizzes").select("*").eq("id", str(quiz_uuid)).eq("user_id", str(user_uuid)).execute()
+ if not quiz_res.data:
+ raise HTTPException(status_code=404, detail="Quiz not found")
+
+ quiz = quiz_res.data[0]
+
+ # Get questions
+ questions_res = client.table("quiz_questions").select("*").eq("quiz_id", str(quiz_uuid)).order("created_at", desc=False).execute()
+
+ quiz["questions"] = questions_res.data
+ return quiz
+
+@router.post("/generate", response_model=QuizResponse, status_code=status.HTTP_201_CREATED)
+async def generate_quiz(
+ req: GenerateQuizRequest,
+ user_id: str = Query(..., description="UUID of the user")
+):
+ user_uuid = validate_uuid(user_id, "user_id")
+ client = get_supabase_client()
+ llm = create_llm(role="planner", temperature=0.7)
+
+ difficulty_instruction = ""
+ if req.difficulty.lower() == "easy":
+ difficulty_instruction = "The difficulty is easy. Ask basic questions suitable for a beginner."
+ elif req.difficulty.lower() == "hard":
+ difficulty_instruction = "The difficulty is hard. Ask advanced, complex questions suitable for an expert."
+ else:
+ difficulty_instruction = "The difficulty is medium. Ask moderately challenging questions suitable for a professional."
+
+ prompt = f"""
+You are an expert educator. Create a {req.count}-question multiple choice quiz on the following topic: '{req.topic}'.
+{difficulty_instruction}
+Return the output EXACTLY as a JSON array of objects.
+Each object must have the following keys:
+- 'question_text': string
+- 'options': array of 4 string options
+- 'correct_option_index': integer (0-3) representing the index of the correct option
+- 'explanation': string explaining why the correct answer is correct
+
+Do not include markdown formatting like ```json ... ```, just output the raw JSON array.
+"""
+ try:
+ from langchain_core.messages import SystemMessage, HumanMessage
+ messages = [
+ SystemMessage(content="You generate valid JSON arrays of quiz questions."),
+ HumanMessage(content=prompt)
+ ]
+ resp = llm.invoke(messages)
+ content = resp.content.strip()
+ if content.startswith("```json"):
+ content = content[7:]
+ if content.startswith("```"):
+ content = content[3:]
+ if content.endswith("```"):
+ content = content[:-3]
+
+ questions_data = json.loads(content)
+
+ if not questions_data or len(questions_data) == 0:
+ raise ValueError("No questions generated")
+
+ # Create Quiz
+ title = f"{req.topic} Quiz"
+ quiz_res = client.table("quizzes").insert({
+ "user_id": str(user_uuid),
+ "title": title,
+ "topic": req.topic,
+ "difficulty": req.difficulty,
+ "total_questions": len(questions_data)
+ }).execute()
+
+ if not quiz_res.data:
+ raise HTTPException(status_code=500, detail="Failed to create quiz in DB")
+
+ quiz = quiz_res.data[0]
+ quiz_uuid = quiz["id"]
+
+ # Create Questions
+ created_questions = []
+ for q in questions_data:
+ insert_res = client.table("quiz_questions").insert({
+ "quiz_id": str(quiz_uuid),
+ "question_text": q["question_text"],
+ "options": q["options"],
+ "correct_option_index": q["correct_option_index"],
+ "explanation": q.get("explanation", "")
+ }).execute()
+ if insert_res.data:
+ created_questions.append(insert_res.data[0])
+
+ quiz["questions"] = created_questions
+ return quiz
+
+ except Exception as e:
+ import logging
+ logging.error(f"Failed to generate quiz: {e}")
+ raise HTTPException(status_code=500, detail=f"AI generation failed: {str(e)}")
+
+@router.post("/{quiz_id}/submit", response_model=QuizResponse)
+async def submit_quiz(
+ quiz_id: str,
+ req: SubmitQuizRequest,
+ user_id: str = Query(..., description="UUID of the user")
+):
+ quiz_uuid = validate_uuid(quiz_id, "quiz_id")
+ user_uuid = validate_uuid(user_id, "user_id")
+ client = get_supabase_client()
+
+ # Get questions
+ questions_res = client.table("quiz_questions").select("*").eq("quiz_id", str(quiz_uuid)).execute()
+ if not questions_res.data:
+ raise HTTPException(status_code=404, detail="Quiz questions not found")
+
+ questions = questions_res.data
+ score = 0
+
+ for q in questions:
+ question_id = str(q["id"])
+ if question_id in req.answers:
+ user_answer = req.answers[question_id]
+ is_correct = user_answer == q["correct_option_index"]
+ if is_correct:
+ score += 1
+
+ # Update user's answer
+ client.table("quiz_questions").update({"user_answer_index": user_answer}).eq("id", question_id).execute()
+
+ # Update quiz score
+ updated_quiz_res = client.table("quizzes").update({"score": score}).eq("id", str(quiz_uuid)).eq("user_id", str(user_uuid)).execute()
+ if not updated_quiz_res.data:
+ raise HTTPException(status_code=500, detail="Failed to update quiz score")
+
+ quiz = updated_quiz_res.data[0]
+
+ # Fetch updated questions
+ updated_questions_res = client.table("quiz_questions").select("*").eq("quiz_id", str(quiz_uuid)).order("created_at", desc=False).execute()
+ quiz["questions"] = updated_questions_res.data
+
+ return quiz
+
+@router.delete("/{quiz_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_quiz(
+ quiz_id: str,
+ user_id: str = Query(..., description="UUID of the user")
+):
+ quiz_uuid = validate_uuid(quiz_id, "quiz_id")
+ user_uuid = validate_uuid(user_id, "user_id")
+ client = get_supabase_client()
+
+ client.table("quizzes").delete().eq("id", str(quiz_uuid)).eq("user_id", str(user_uuid)).execute()
+ return None
+
+@router.post("/{quiz_id}/retake", response_model=QuizResponse)
+async def retake_quiz(
+ quiz_id: str,
+ user_id: str = Query(..., description="UUID of the user")
+):
+ quiz_uuid = validate_uuid(quiz_id, "quiz_id")
+ user_uuid = validate_uuid(user_id, "user_id")
+ client = get_supabase_client()
+
+ # Check if quiz exists and belongs to user
+ quiz_res = client.table("quizzes").select("*").eq("id", str(quiz_uuid)).eq("user_id", str(user_uuid)).execute()
+ if not quiz_res.data:
+ raise HTTPException(status_code=404, detail="Quiz not found")
+
+ # Reset quiz score
+ updated_quiz_res = client.table("quizzes").update({"score": None}).eq("id", str(quiz_uuid)).eq("user_id", str(user_uuid)).execute()
+ if not updated_quiz_res.data:
+ raise HTTPException(status_code=500, detail="Failed to reset quiz score")
+
+ quiz = updated_quiz_res.data[0]
+
+ # Reset all questions' user_answer_index
+ client.table("quiz_questions").update({"user_answer_index": None}).eq("quiz_id", str(quiz_uuid)).execute()
+
+ # Fetch reset questions
+ updated_questions_res = client.table("quiz_questions").select("*").eq("quiz_id", str(quiz_uuid)).order("created_at", desc=False).execute()
+ quiz["questions"] = updated_questions_res.data
+
+ return quiz
diff --git a/backend/app/main.py b/backend/app/main.py
index e6c8377..959f479 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -8,7 +8,7 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
-from app.api.routes import upload, videos, webhooks, flashcards
+from app.api.routes import upload, videos, webhooks, flashcards, quizzes
from app.config import get_settings
# Get application settings
@@ -44,6 +44,7 @@
app.include_router(videos.router, prefix="/api")
app.include_router(webhooks.router, prefix="/api")
app.include_router(flashcards.router, prefix="/api")
+app.include_router(quizzes.router, prefix="/api")
@app.get("/")
diff --git a/backend/app/models/quiz.py b/backend/app/models/quiz.py
new file mode 100644
index 0000000..753db8f
--- /dev/null
+++ b/backend/app/models/quiz.py
@@ -0,0 +1,37 @@
+from pydantic import BaseModel, Field
+from typing import List, Optional
+from datetime import datetime
+from uuid import UUID
+
+class QuizQuestionCreate(BaseModel):
+ question_text: str
+ options: List[str]
+ correct_option_index: int
+ explanation: Optional[str] = None
+
+class QuizQuestionResponse(QuizQuestionCreate):
+ id: UUID
+ quiz_id: UUID
+ user_answer_index: Optional[int] = None
+ created_at: datetime
+
+class QuizCreate(BaseModel):
+ title: str
+ topic: str
+ difficulty: str
+ total_questions: int
+
+class QuizResponse(QuizCreate):
+ id: UUID
+ user_id: UUID
+ score: Optional[int] = None
+ created_at: datetime
+ questions: Optional[List[QuizQuestionResponse]] = None
+
+class GenerateQuizRequest(BaseModel):
+ topic: str
+ difficulty: str = Field(default="medium")
+ count: int = Field(default=5, ge=1, le=20)
+
+class SubmitQuizRequest(BaseModel):
+ answers: dict[str, int] # Mapping of question_id (str) to user_answer_index (int)
diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx
index f2daa0f..aa171c8 100644
--- a/frontend/app/dashboard/page.tsx
+++ b/frontend/app/dashboard/page.tsx
@@ -46,9 +46,9 @@ export default async function DashboardPage() {
{/* Generation Pipeline (appears after Generate is clicked) */}
- {/* Flashcards Quick Link */}
-
-
+ {/* Quick Links */}
+
+
@@ -62,6 +62,21 @@ export default async function DashboardPage() {
+
+
+
+
+
+
+
Take Quizzes
+
Test your knowledge with AI-generated quizzes and track your score.
+
+
+
+
+
diff --git a/frontend/app/quizzes/[quizId]/page.tsx b/frontend/app/quizzes/[quizId]/page.tsx
new file mode 100644
index 0000000..05b25d0
--- /dev/null
+++ b/frontend/app/quizzes/[quizId]/page.tsx
@@ -0,0 +1,358 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useParams, useRouter } from "next/navigation";
+import { getQuizById, submitQuiz, retakeQuiz, Quiz } from "@/lib/api";
+import { createClient } from "@/utils/supabase/client";
+import { Button } from "@/components/ui/button";
+import { toast } from "sonner";
+import { ArrowLeft, ArrowRight, CheckCircle2, XCircle, ChevronLeft, RefreshCcw } from "lucide-react";
+import Link from "next/link";
+import { cn } from "@/lib/utils";
+import { DashboardWrapper } from "@/components/dashboard/DashboardWrapper";
+import { Sidebar } from "@/components/dashboard/Sidebar";
+import { Header } from "@/components/dashboard/Header";
+
+export default function QuizSessionPage() {
+ const [userId, setUserId] = useState
(null);
+ const params = useParams();
+ const router = useRouter();
+ const quizId = params.quizId as string;
+
+ const [quiz, setQuiz] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ // State for the active session
+ const [currentIndex, setCurrentIndex] = useState(0);
+ const [userAnswers, setUserAnswers] = useState>({});
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [isRetaking, setIsRetaking] = useState(false);
+
+ useEffect(() => {
+ const fetchQuiz = async () => {
+ const supabase = createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+
+ if (!user) {
+ setIsLoading(false);
+ return;
+ }
+ setUserId(user.id);
+
+ try {
+ const data = await getQuizById(user.id, quizId);
+ setQuiz(data);
+
+ // If it was already submitted before, prepopulate answers
+ if (data.score !== null && data.score !== undefined && data.questions) {
+ const pastAnswers: Record = {};
+ data.questions.forEach((q) => {
+ if (q.user_answer_index !== null && q.user_answer_index !== undefined) {
+ pastAnswers[q.id] = q.user_answer_index;
+ }
+ });
+ setUserAnswers(pastAnswers);
+ }
+ } catch (error) {
+ console.error(error);
+ toast.error("Failed to load quiz");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ fetchQuiz();
+ }, [quizId]);
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (!quiz || !quiz.questions || quiz.questions.length === 0) {
+ return (
+
+
Quiz not found
+
The quiz you are looking for does not exist.
+
+
+ );
+ }
+
+ const isCompleted = quiz.score !== null && quiz.score !== undefined;
+ const currentQuestion = quiz.questions[currentIndex];
+
+ const handleSelectOption = (optionIndex: number) => {
+ if (isCompleted) return; // Don't allow changing answers if already submitted
+ setUserAnswers((prev) => ({
+ ...prev,
+ [currentQuestion.id]: optionIndex
+ }));
+ };
+
+ const handleSubmit = async () => {
+ if (!userId) return;
+
+ // Check if all answered
+ if (Object.keys(userAnswers).length < quiz.questions!.length) {
+ const confirm = window.confirm("You haven't answered all questions. Are you sure you want to submit?");
+ if (!confirm) return;
+ }
+
+ try {
+ setIsSubmitting(true);
+ const updatedQuiz = await submitQuiz(userId, quizId, userAnswers);
+ setQuiz(updatedQuiz);
+ toast.success("Quiz submitted successfully!");
+ window.scrollTo(0, 0); // Scroll to top for results
+ } catch (error) {
+ console.error(error);
+ toast.error("Failed to submit quiz");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleRetake = async () => {
+ if (!userId) return;
+ try {
+ setIsRetaking(true);
+ const resetQuiz = await retakeQuiz(userId, quizId);
+ setQuiz(resetQuiz);
+ setUserAnswers({});
+ setCurrentIndex(0);
+ toast.success("Quiz reset. Good luck!");
+ } catch (error) {
+ console.error(error);
+ toast.error("Failed to retake quiz");
+ } finally {
+ setIsRetaking(false);
+ }
+ };
+
+ // --- RENDER COMPLETED STATE ---
+ if (isCompleted) {
+ const percentage = Math.round((quiz.score! / quiz.total_questions) * 100);
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
Back to Quizzes
+
+
+
+
{quiz.title}
+
You scored {quiz.score} out of {quiz.total_questions}
+
+
+
+ {percentage}%
+
+
+
+
+
+
+
+
Detailed Review
+ {quiz.questions.map((q, idx) => {
+ const isCorrect = q.user_answer_index === q.correct_option_index;
+ return (
+
+
+ {isCorrect ? (
+
+ ) : (
+
+ )}
+
+
+ {idx + 1}. {q.question_text}
+
+
+
+
+
+ {q.options.map((opt, optIdx) => {
+ const isSelected = q.user_answer_index === optIdx;
+ const isActualCorrect = q.correct_option_index === optIdx;
+
+ let optionStyle = "border-slate-200 dark:border-border bg-white dark:bg-card text-slate-700 dark:text-slate-300";
+ if (isActualCorrect) {
+ optionStyle = "border-green-500 bg-green-50 dark:bg-green-500/10 text-green-700 dark:text-green-400 font-medium";
+ } else if (isSelected && !isActualCorrect) {
+ optionStyle = "border-red-500 bg-red-50 dark:bg-red-500/10 text-red-700 dark:text-red-400";
+ }
+
+ return (
+
+
+ {opt}
+ {isSelected && Your Answer}
+ {isActualCorrect && !isSelected && Correct Answer}
+
+
+ );
+ })}
+
+
+ {q.explanation && (
+
+ Explanation:
+ {q.explanation}
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+
+ );
+ }
+
+ // --- RENDER ACTIVE SESSION STATE ---
+ const progressPercentage = ((currentIndex + 1) / quiz.total_questions) * 100;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
Save & Exit
+
+
+ {/* Progress Bar */}
+
+
+ Question {currentIndex + 1} of {quiz.total_questions}
+ {Math.round(progressPercentage)}%
+
+
+
+
+ {/* Question Card */}
+
+ {/* Subtle decorative glow */}
+
+
+
+ {currentQuestion.question_text}
+
+
+
+ {currentQuestion.options.map((option, idx) => {
+ const isSelected = userAnswers[currentQuestion.id] === idx;
+
+ return (
+
+ );
+ })}
+
+
+
+ {/* Navigation */}
+
+
+
+ {currentIndex === quiz.total_questions - 1 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/quizzes/page.tsx b/frontend/app/quizzes/page.tsx
new file mode 100644
index 0000000..2d77d81
--- /dev/null
+++ b/frontend/app/quizzes/page.tsx
@@ -0,0 +1,175 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Plus, Clock, Target, Trash2 } from "lucide-react";
+import { getQuizzes, deleteQuiz, Quiz } from "@/lib/api";
+import { createClient } from "@/utils/supabase/client";
+import { Button } from "@/components/ui/button";
+import { DashboardWrapper } from "@/components/dashboard/DashboardWrapper";
+import { Sidebar } from "@/components/dashboard/Sidebar";
+import { Header } from "@/components/dashboard/Header";
+import { formatDistanceToNow } from "date-fns";
+import { CreateQuizModal } from "@/components/quizzes/CreateQuizModal";
+import Link from "next/link";
+import { toast } from "sonner";
+
+export default function QuizzesPage() {
+ const [userId, setUserId] = useState(null);
+ const [quizzes, setQuizzes] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+
+ const fetchQuizzes = async (uid: string) => {
+ if (!uid) return;
+ try {
+ setIsLoading(true);
+ const data = await getQuizzes(uid);
+ setQuizzes(data);
+ } catch (error) {
+ console.error("Failed to fetch quizzes", error);
+ toast.error("Failed to load quizzes");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ const init = async () => {
+ const supabase = createClient();
+ const { data: { user: supabaseUser } } = await supabase.auth.getUser();
+ if (supabaseUser) {
+ setUserId(supabaseUser.id);
+ fetchQuizzes(supabaseUser.id);
+ } else {
+ setIsLoading(false);
+ }
+ };
+ init();
+ }, []);
+
+ const handleDelete = async (quizId: string, e: React.MouseEvent) => {
+ e.preventDefault(); // prevent navigation
+ if (!userId) return;
+ try {
+ await deleteQuiz(userId, quizId);
+ toast.success("Quiz deleted");
+ fetchQuizzes(userId);
+ } catch (error) {
+ console.error(error);
+ toast.error("Failed to delete quiz");
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Quizzes & Tests
+
Test your knowledge with AI-generated quizzes.
+
+
+
+
+ {isLoading ? (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ ) : quizzes.length === 0 ? (
+
+
+
No quizzes yet
+
Generate your first AI quiz to start testing your knowledge.
+
+
+ ) : (
+
+ {quizzes.map((quiz) => (
+
+
+
+
+
+
+
+
+
+ {quiz.title}
+
+ {quiz.difficulty && (
+
+ {quiz.difficulty}
+
+ )}
+
+
+
+
+ {quiz.total_questions} Questions
+
+
+
+ {formatDistanceToNow(new Date(quiz.created_at), { addSuffix: true })}
+
+
+
+
+
+ {quiz.score !== null && quiz.score !== undefined ? (
+
+ Score
+
+ {quiz.score} / {quiz.total_questions} ({(quiz.score / quiz.total_questions * 100).toFixed(0)}%)
+
+
+ ) : (
+
+ Status
+ Not Started
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+
setIsCreateModalOpen(false)}
+ onQuizCreated={() => userId && fetchQuizzes(userId)}
+ />
+
+
+
+
+
+ );
+}
diff --git a/frontend/components/dashboard/Sidebar.tsx b/frontend/components/dashboard/Sidebar.tsx
index ee82e05..90fd7fb 100644
--- a/frontend/components/dashboard/Sidebar.tsx
+++ b/frontend/components/dashboard/Sidebar.tsx
@@ -4,7 +4,7 @@ import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { Button } from "@/components/ui/button";
-import { Library, LayoutDashboard, Brain } from "lucide-react";
+import { Library, LayoutDashboard, Brain, PenTool } from "lucide-react";
import { cn } from "@/lib/utils";
export function Sidebar({ className }: { className?: string }) {
@@ -71,6 +71,20 @@ export function Sidebar({ className }: { className?: string }) {
Flashcards
+
+
+
diff --git a/frontend/components/quizzes/CreateQuizModal.tsx b/frontend/components/quizzes/CreateQuizModal.tsx
new file mode 100644
index 0000000..b55ed4a
--- /dev/null
+++ b/frontend/components/quizzes/CreateQuizModal.tsx
@@ -0,0 +1,131 @@
+import { useState } from "react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { generateQuiz } from "@/lib/api";
+import { createClient } from "@/utils/supabase/client";
+import { useEffect } from "react";
+import { toast } from "sonner";
+import { Loader2 } from "lucide-react";
+import { useRouter } from "next/navigation";
+
+interface CreateQuizModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onQuizCreated: () => void;
+}
+
+export function CreateQuizModal({ isOpen, onClose, onQuizCreated }: CreateQuizModalProps) {
+ const [userId, setUserId] = useState(null);
+
+ useEffect(() => {
+ const fetchUser = async () => {
+ const supabase = createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+ if (user) setUserId(user.id);
+ };
+ fetchUser();
+ }, []);
+ const router = useRouter();
+ const [topic, setTopic] = useState("");
+ const [difficulty, setDifficulty] = useState("Medium");
+ const [count, setCount] = useState(5);
+ const [isGenerating, setIsGenerating] = useState(false);
+
+ const handleGenerate = async () => {
+ if (!userId) return;
+ if (!topic.trim()) {
+ toast.error("Please enter a topic");
+ return;
+ }
+
+ try {
+ setIsGenerating(true);
+ const newQuiz = await generateQuiz(userId, topic, count, difficulty);
+ toast.success("Quiz generated successfully!");
+ setTopic("");
+ setDifficulty("Medium");
+ setCount(5);
+ onQuizCreated();
+ onClose();
+ // Navigate straight to the quiz
+ router.push(`/quizzes/${newQuiz.id}`);
+ } catch (error) {
+ console.error(error);
+ toast.error("Failed to generate quiz. Please try again.");
+ } finally {
+ setIsGenerating(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/components/ui/label.tsx b/frontend/components/ui/label.tsx
new file mode 100644
index 0000000..1ac80f7
--- /dev/null
+++ b/frontend/components/ui/label.tsx
@@ -0,0 +1,24 @@
+"use client"
+
+import * as React from "react"
+import { Label as LabelPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Label({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/frontend/components/ui/select.tsx b/frontend/components/ui/select.tsx
new file mode 100644
index 0000000..c0dc712
--- /dev/null
+++ b/frontend/components/ui/select.tsx
@@ -0,0 +1,190 @@
+"use client"
+
+import * as React from "react"
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
+import { Select as SelectPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Select({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectGroup({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "item-aligned",
+ align = "center",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
index 7356d00..5366034 100644
--- a/frontend/lib/api.ts
+++ b/frontend/lib/api.ts
@@ -345,3 +345,89 @@ export async function generateCards(userId: string, deckId: string, topic: strin
if (!response.ok) throw new Error("Failed to generate cards");
return response.json();
}
+
+// ============================================================================
+// Quizzes API
+// ============================================================================
+
+export interface QuizQuestion {
+ id: string;
+ quiz_id: string;
+ question_text: string;
+ options: string[];
+ correct_option_index: number;
+ explanation?: string;
+ user_answer_index?: number;
+ created_at: string;
+}
+
+export interface Quiz {
+ id: string;
+ user_id: string;
+ title: string;
+ topic: string;
+ difficulty: string;
+ score: number | null;
+ total_questions: number;
+ created_at: string;
+ questions?: QuizQuestion[];
+}
+
+export async function getQuizzes(userId: string): Promise {
+ const url = new URL(`${API_BASE_URL}/api/quizzes`);
+ url.searchParams.set("user_id", userId);
+ const response = await fetch(url.toString());
+ if (!response.ok) throw new Error("Failed to fetch quizzes");
+ return response.json();
+}
+
+export async function generateQuiz(userId: string, topic: string, count: number, difficulty: string): Promise {
+ const url = new URL(`${API_BASE_URL}/api/quizzes/generate`);
+ url.searchParams.set("user_id", userId);
+ const response = await fetch(url.toString(), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ topic, count, difficulty })
+ });
+ if (!response.ok) throw new Error("Failed to generate quiz");
+ return response.json();
+}
+
+export async function getQuizById(userId: string, quizId: string): Promise {
+ const url = new URL(`${API_BASE_URL}/api/quizzes/${quizId}`);
+ url.searchParams.set("user_id", userId);
+ const response = await fetch(url.toString());
+ if (!response.ok) throw new Error("Failed to fetch quiz");
+ return response.json();
+}
+
+export async function submitQuiz(userId: string, quizId: string, answers: Record): Promise {
+ const url = new URL(`${API_BASE_URL}/api/quizzes/${quizId}/submit`);
+ url.searchParams.set("user_id", userId);
+ const response = await fetch(url.toString(), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ answers })
+ });
+ if (!response.ok) throw new Error("Failed to submit quiz");
+ return response.json();
+}
+
+export async function deleteQuiz(userId: string, quizId: string): Promise {
+ const url = new URL(`${API_BASE_URL}/api/quizzes/${quizId}`);
+ url.searchParams.set("user_id", userId);
+ const response = await fetch(url.toString(), {
+ method: "DELETE"
+ });
+ if (!response.ok) throw new Error("Failed to delete quiz");
+}
+
+export async function retakeQuiz(userId: string, quizId: string): Promise {
+ const url = new URL(`${API_BASE_URL}/api/quizzes/${quizId}/retake`);
+ url.searchParams.set("user_id", userId);
+ const response = await fetch(url.toString(), {
+ method: "POST"
+ });
+ if (!response.ok) throw new Error("Failed to reset quiz");
+ return response.json();
+}
diff --git a/frontend/package.json b/frontend/package.json
index a464d72..838859d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -19,12 +19,14 @@
"@supabase/supabase-js": "^2.86.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "date-fns": "^4.4.0",
"lucide-react": "^0.562.0",
"next": "16.0.10",
"next-themes": "^0.4.6",
"radix-ui": "^1.6.0",
"react": "19.2.0",
"react-dom": "19.2.0",
+ "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 3674b4e..66b56f5 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -38,6 +38,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
+ date-fns:
+ specifier: ^4.4.0
+ version: 4.4.0
lucide-react:
specifier: ^0.562.0
version: 0.562.0(react@19.2.0)
@@ -56,6 +59,9 @@ importers:
react-dom:
specifier: 19.2.0
version: 19.2.0(react@19.2.0)
+ sonner:
+ specifier: ^2.0.7
+ version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0
@@ -2001,6 +2007,9 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'}
+ date-fns@4.4.0:
+ resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
+
debug@3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
@@ -2968,6 +2977,12 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
+ sonner@2.0.7:
+ resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -5119,6 +5134,8 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
+ date-fns@4.4.0: {}
+
debug@3.2.7:
dependencies:
ms: 2.1.3
@@ -6299,6 +6316,11 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
+ sonner@2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
+ dependencies:
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+
source-map-js@1.2.1: {}
stable-hash@0.0.5: {}
diff --git a/supabase/migrations/20260122000000_create_quizzes.sql b/supabase/migrations/20260122000000_create_quizzes.sql
new file mode 100644
index 0000000..476d2ef
--- /dev/null
+++ b/supabase/migrations/20260122000000_create_quizzes.sql
@@ -0,0 +1,20 @@
+CREATE TABLE IF NOT EXISTS quizzes (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL,
+ title TEXT NOT NULL,
+ topic TEXT NOT NULL,
+ score INTEGER DEFAULT NULL,
+ total_questions INTEGER NOT NULL,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS quiz_questions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ quiz_id UUID NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE,
+ question_text TEXT NOT NULL,
+ options JSONB NOT NULL,
+ correct_option_index INTEGER NOT NULL,
+ explanation TEXT,
+ user_answer_index INTEGER DEFAULT NULL,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
diff --git a/supabase/migrations/20260123000000_add_quiz_difficulty.sql b/supabase/migrations/20260123000000_add_quiz_difficulty.sql
new file mode 100644
index 0000000..f2d4a3b
--- /dev/null
+++ b/supabase/migrations/20260123000000_add_quiz_difficulty.sql
@@ -0,0 +1 @@
+ALTER TABLE quizzes ADD COLUMN difficulty TEXT NOT NULL DEFAULT 'medium';
From 134ad6f6cf37fe09dfd831d99e22dbc87f315b8e Mon Sep 17 00:00:00 2001
From: Mustansir Rangwala <119410651+mustansirr@users.noreply.github.com>
Date: Sun, 28 Jun 2026 19:31:18 +0530
Subject: [PATCH 2/3] perf(library): optimize modal close refetching and
restore video frame preview
---
frontend/components/dashboard/VideoGrid.tsx | 22 +++++++++++----------
1 file changed, 12 insertions(+), 10 deletions(-)
diff --git a/frontend/components/dashboard/VideoGrid.tsx b/frontend/components/dashboard/VideoGrid.tsx
index 5611818..7a8ce77 100644
--- a/frontend/components/dashboard/VideoGrid.tsx
+++ b/frontend/components/dashboard/VideoGrid.tsx
@@ -9,6 +9,7 @@ import {
Trash2,
Loader2,
VideoOff,
+ Film,
X,
AlertTriangle,
Search,
@@ -117,17 +118,18 @@ function VideoCard({
preload="metadata"
muted
/>
+
{
e.stopPropagation();
onPlay(video);
}}
>
>
@@ -326,10 +328,10 @@ export function VideoGrid() {
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
- const fetchVideos = useCallback(async () => {
+ const fetchVideos = useCallback(async (showLoader: boolean = true) => {
try {
- setLoading(true);
- setError(null);
+ if (showLoader) setLoading(true);
+ if (showLoader) setError(null);
const supabase = createClient();
const {
@@ -344,9 +346,9 @@ export function VideoGrid() {
const data = await listUserVideos(user.id);
setVideos(data);
} catch (err) {
- setError(err instanceof Error ? err.message : "Failed to load videos");
+ if (showLoader) setError(err instanceof Error ? err.message : "Failed to load videos");
} finally {
- setLoading(false);
+ if (showLoader) setLoading(false);
}
}, []);
@@ -508,8 +510,8 @@ export function VideoGrid() {
videoPrompt={selectedVideo.prompt}
onClose={() => {
setSelectedVideo(null);
- // Refresh the list in case status changed (e.g. approved scripts)
- fetchVideos();
+ // Refresh the list silently in case status changed (e.g. approved scripts)
+ fetchVideos(false);
}}
/>
)}
From e1cf9f646e86cfee646b383b4f267325fd30a797 Mon Sep 17 00:00:00 2001
From: Mustansir Rangwala <119410651+mustansirr@users.noreply.github.com>
Date: Sun, 28 Jun 2026 19:35:43 +0530
Subject: [PATCH 3/3] fix(library): resolve type error on try again button
---
frontend/components/dashboard/VideoGrid.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/components/dashboard/VideoGrid.tsx b/frontend/components/dashboard/VideoGrid.tsx
index 7a8ce77..b4a52e3 100644
--- a/frontend/components/dashboard/VideoGrid.tsx
+++ b/frontend/components/dashboard/VideoGrid.tsx
@@ -392,7 +392,7 @@ export function VideoGrid() {
{error}
-