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/dashboard/VideoGrid.tsx b/frontend/components/dashboard/VideoGrid.tsx
index 5611818..b4a52e3 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);
}
}, []);
@@ -390,7 +392,7 @@ export function VideoGrid() {
{error}
-