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}

+ +
+ + = 70 ? "text-green-500" : percentage >= 40 ? "text-amber-500" : "text-red-500"} + strokeWidth="8" + strokeDasharray={`${percentage * 2.83} 283`} + strokeLinecap="round" + stroke="currentColor" + fill="transparent" + r="45" + cx="50" + cy="50" + /> + + {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}

-
@@ -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); }} /> )} 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 ( + !open && !isGenerating && onClose()}> + + + Generate AI Quiz + + Enter a topic and our AI will generate a multiple-choice quiz for you. + + +
+
+ + setTopic(e.target.value)} + disabled={isGenerating} + /> +
+
+ +
+ setCount(parseInt(e.target.value) || 5)} + disabled={isGenerating} + className="w-24" + /> + Max 20 +
+
+
+ + +
+
+ + + + +
+
+ ); +} 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';