Download src/components/study-chat/StudyGenerator.tsx from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 32.8 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/study-chat/StudyGenerator.tsx
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/components/study-chat/StudyGenerator.tsx
-
curl -L -o StudyGenerator.tsx https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/study-chat/StudyGenerator.tsx
32.8 kB
| "use client"; | |
| import { useCallback, useRef, useState } from "react"; | |
| import { useRouter } from "next/navigation"; | |
| import { | |
| ArrowLeft, ArrowRight, BookOpen, Check, ChevronDown, | |
| ClipboardList, FileQuestion, FileText, GraduationCap, | |
| Layers3, LoaderCircle, Moon, RefreshCw, Sparkles, Upload, Video, X, | |
| } from "lucide-react"; | |
| import { cn } from "@/lib/utils"; | |
| import { | |
| generateBrainNotes, generateBrainQuiz, generateBrainFlashcards, | |
| generateBrainVideoPlan, generateBrainPyqAnalysis, | |
| } from "@/lib/api/brain"; | |
| import { uploadDocument } from "@/lib/api/documents"; | |
| import type { ApiDocument, BrainGeneratePayload, BrainVideoPlanPayload, BrainVideoPlanResponse } from "@/lib/api/types"; | |
| import type { NotesOutput, QuizQuestionResponse, FlashcardResponseItem } from "@/lib/api/types"; | |
| import type { BrainPyqAnalysisOutput } from "@/lib/api/types"; | |
| import type { VideoMode } from "@/lib/api/types"; | |
| import { MATERIAL_UPLOAD_ACCEPT } from "@/lib/uploads/document-upload-contract"; | |
| import { validateStudyFile } from "./upload-guards"; | |
| type GeneratorStep = "upload" | "context" | "generating" | "results"; | |
| type StudyKit = { | |
| notes: NotesOutput | null; | |
| quiz: { questions: QuizQuestionResponse[] } | null; | |
| flashcards: { cards: FlashcardResponseItem[] } | null; | |
| videoPlan: BrainVideoPlanResponse | null; | |
| pyqAnalysis: BrainPyqAnalysisOutput | null; | |
| generatedAt: string; | |
| }; | |
| type ContextFields = { | |
| classLevel: string; | |
| board: string; | |
| subject: string; | |
| chapter: string; | |
| materialType: string; | |
| goal: string; | |
| availableTime: string; | |
| outputs: string[]; | |
| }; | |
| const OUTPUT_OPTIONS = [ | |
| { id: "notes", label: "Smart Notes", icon: ClipboardList }, | |
| { id: "quiz", label: "Practice Quiz", icon: FileQuestion }, | |
| { id: "flashcards", label: "Flashcards", icon: Layers3 }, | |
| { id: "video", label: "Study Video", icon: Video }, | |
| { id: "pyq", label: "PYQ Analysis", icon: GraduationCap }, | |
| ] as const; | |
| const CLASS_OPTIONS = ["Plus One", "Plus Two", "Class 10", "Class 9"]; | |
| const BOARD_OPTIONS = ["Kerala HSE", "CBSE", "ICSE", "Other"]; | |
| const SUBJECT_OPTIONS = ["Physics", "Chemistry", "Mathematics", "Biology", "Computer Science", "English"]; | |
| const GOAL_OPTIONS = [ | |
| { value: "exam", label: "Board exam prep" }, | |
| { value: "revision", label: "Quick revision" }, | |
| { value: "doubt", label: "Doubt solving" }, | |
| { value: "video-only", label: "Watch study video" }, | |
| { value: "deep-learn", label: "Deep learning" }, | |
| ]; | |
| const TIME_OPTIONS = [ | |
| { value: "30min", label: "30 minutes" }, | |
| { value: "1h", label: "1 hour" }, | |
| { value: "2h", label: "2 hours" }, | |
| { value: "5h", label: "5 hours" }, | |
| { value: "tonight", label: "All night" }, | |
| { value: "tomorrow", label: "Tomorrow" }, | |
| { value: "week", label: "A week+" }, | |
| ]; | |
| function buildPayload(topic: string, subject: string, sourceIds: string[], extra?: Record<string, unknown>): BrainGeneratePayload { | |
| return { | |
| topic, | |
| source_id: sourceIds[0] ?? null, | |
| source_ids: sourceIds, | |
| subject, | |
| study_profile_id: null, | |
| level: null, | |
| language_preference: null, | |
| ...extra, | |
| } as BrainGeneratePayload; | |
| } | |
| function estimateStudyTime(context: ContextFields): string { | |
| const t = context.availableTime; | |
| if (t === "30min") return "30 minutes"; | |
| if (t === "1h") return "1 hour"; | |
| if (t === "2h") return "2 hours"; | |
| if (t === "5h") return "5 hours"; | |
| if (t === "tonight") return "3-4 hours"; | |
| if (t === "tomorrow") return "5-6 hours"; | |
| if (t === "week") return "8-10 hours across the week"; | |
| return "1-2 hours"; | |
| } | |
| function sourceTitle(doc: ApiDocument): string { | |
| return doc.title || doc.file_name || "Untitled source"; | |
| } | |
| export function StudyGenerator() { | |
| const router = useRouter(); | |
| const [step, setStep] = useState<GeneratorStep>("upload"); | |
| const [uploadedDocs, setUploadedDocs] = useState<ApiDocument[]>([]); | |
| const [isUploading, setIsUploading] = useState(false); | |
| const [uploadError, setUploadError] = useState<string | null>(null); | |
| const [dragOver, setDragOver] = useState(false); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const [context, setContext] = useState<ContextFields>({ | |
| classLevel: "", board: "", subject: "", chapter: "", | |
| materialType: "", goal: "", availableTime: "", outputs: [], | |
| }); | |
| const [questionStep, setQuestionStep] = useState(0); | |
| const [kit, setKit] = useState<StudyKit | null>(null); | |
| const [genProgress, setGenProgress] = useState(""); | |
| const [genError, setGenError] = useState<string | null>(null); | |
| const [expandedSection, setExpandedSection] = useState<string | null>(null); | |
| const missingQuestions: Array<{ | |
| key: keyof ContextFields; | |
| label: string; | |
| options?: readonly string[] | readonly { value: string; label: string }[]; | |
| type?: "select" | "multiselect"; | |
| }> = []; | |
| if (!context.classLevel) missingQuestions.push({ key: "classLevel", label: "Which class?", options: CLASS_OPTIONS }); | |
| if (!context.board) missingQuestions.push({ key: "board", label: "Which syllabus/board?", options: BOARD_OPTIONS }); | |
| if (!context.subject) missingQuestions.push({ key: "subject", label: "Which subject?", options: SUBJECT_OPTIONS }); | |
| if (!context.chapter) missingQuestions.push({ key: "chapter", label: "Which chapter or topic?" }); | |
| if (!context.goal) missingQuestions.push({ key: "goal", label: "What's your goal?", options: GOAL_OPTIONS as unknown as readonly { value: string; label: string }[] }); | |
| if (!context.availableTime) missingQuestions.push({ key: "availableTime", label: "How much time do you have?", options: TIME_OPTIONS as unknown as readonly { value: string; label: string }[] }); | |
| if (context.outputs.length === 0) missingQuestions.push({ key: "outputs", label: "What output do you want?", options: OUTPUT_OPTIONS.map(o => ({ value: o.id, label: o.label })), type: "multiselect" }); | |
| const currentQuestion = questionStep < missingQuestions.length ? missingQuestions[questionStep] : null; | |
| const allContextCollected = missingQuestions.length === 0; | |
| const handleFile = useCallback(async (file: File) => { | |
| const validation = validateStudyFile(file, "material"); | |
| if (!validation.ok) { setUploadError(validation.message); return; } | |
| setIsUploading(true); | |
| setUploadError(null); | |
| try { | |
| const doc = await uploadDocument({ file, title: file.name.replace(/\.[^/.]+$/, "") }); | |
| setUploadedDocs(prev => [...prev, doc]); | |
| } catch { setUploadError("Upload failed. Try again."); } | |
| finally { setIsUploading(false); } | |
| }, []); | |
| const handleDrop = useCallback((e: React.DragEvent) => { | |
| e.preventDefault(); | |
| setDragOver(false); | |
| const file = e.dataTransfer.files?.[0]; | |
| if (file) handleFile(file); | |
| }, [handleFile]); | |
| const handleContextSelect = (key: keyof ContextFields, value: string | string[]) => { | |
| setContext(prev => ({ ...prev, [key]: value })); | |
| setQuestionStep(s => s + 1); | |
| }; | |
| const handleOutputToggle = useCallback((id: string) => { | |
| setContext(prev => ({ | |
| ...prev, | |
| outputs: prev.outputs.includes(id) ? prev.outputs.filter(o => o !== id) : [...prev.outputs, id], | |
| })); | |
| }, []); | |
| const handleGenerate = useCallback(async () => { | |
| setGenError(null); | |
| setStep("generating"); | |
| const topic = context.chapter || uploadedDocs[0]?.chapter || "this topic"; | |
| const subject = context.subject || uploadedDocs[0]?.subject || "General"; | |
| const sourceIds = uploadedDocs.map(d => d.id); | |
| const results: StudyKit = { notes: null, quiz: null, flashcards: null, videoPlan: null, pyqAnalysis: null, generatedAt: new Date().toISOString() }; | |
| try { | |
| if (context.outputs.includes("notes") || context.outputs.length === 0) { | |
| setGenProgress("Creating study summary and smart notes..."); | |
| try { | |
| const notes = await generateBrainNotes(buildPayload(topic, subject, sourceIds, { options: { mode: "smart_notes" } })); | |
| results.notes = notes.output; | |
| } catch { /* notes unavailable */ } | |
| } | |
| if (context.outputs.includes("quiz") || context.outputs.length === 0) { | |
| setGenProgress("Building practice quiz..."); | |
| try { | |
| const quiz = await generateBrainQuiz(buildPayload(topic, subject, sourceIds, { options: { num_questions: 8 } })); | |
| results.quiz = quiz.output; | |
| } catch { /* quiz unavailable */ } | |
| } | |
| if (context.outputs.includes("flashcards") || context.outputs.length === 0) { | |
| setGenProgress("Making flashcards..."); | |
| try { | |
| const cards = await generateBrainFlashcards(buildPayload(topic, subject, sourceIds, { options: { card_count: 10 } })); | |
| results.flashcards = cards.output; | |
| } catch { /* flashcards unavailable */ } | |
| } | |
| if (context.outputs.includes("video") || context.outputs.length === 0) { | |
| setGenProgress("Planning study video..."); | |
| try { | |
| const videoPayload: BrainVideoPlanPayload = { | |
| topic, | |
| video_mode: "exam_focus" as VideoMode, | |
| teaching_style: "normal_teacher" as const, | |
| format: "16:9" as const, | |
| source_id: sourceIds[0] ?? null, | |
| source_ids: sourceIds, | |
| level: null, | |
| }; | |
| const videoPlan = await generateBrainVideoPlan(videoPayload); | |
| results.videoPlan = videoPlan; | |
| } catch { /* video plan unavailable */ } | |
| } | |
| if (context.outputs.includes("pyq")) { | |
| setGenProgress("Analyzing question patterns..."); | |
| try { | |
| const pyq = await generateBrainPyqAnalysis(buildPayload(topic, subject, sourceIds, { options: { raw_text: `Analyze PYQ patterns for ${topic} - ${subject} ${context.board}` } })); | |
| results.pyqAnalysis = pyq.output; | |
| } catch { /* pyq unavailable */ } | |
| } | |
| setKit(results); | |
| setStep("results"); | |
| } catch (err) { | |
| setGenError(err instanceof Error ? err.message : "Generation failed. Try again."); | |
| setStep("upload"); | |
| } finally { | |
| setGenProgress(""); | |
| } | |
| }, [context, uploadedDocs]); | |
| // triggered directly when context collection completes | |
| const renderUpload = () => ( | |
| <div className="mx-auto flex w-full max-w-2xl flex-col gap-6 py-8"> | |
| <div className="text-center"> | |
| <h1 className="text-3xl font-bold tracking-tight text-white">Study Generator</h1> | |
| <p className="mt-2 text-sm text-white/60">Upload notes, textbook pages, question papers, or any study material. DocDoe generates your complete study kit.</p> | |
| </div> | |
| <div | |
| onDragOver={e => { e.preventDefault(); setDragOver(true); }} | |
| onDragLeave={() => setDragOver(false)} | |
| onDrop={handleDrop} | |
| onClick={() => fileInputRef.current?.click()} | |
| className={cn( | |
| "flex cursor-pointer flex-col items-center gap-4 rounded-2xl border-2 border-dashed p-12 transition", | |
| dragOver ? "border-white/40 bg-white/[0.06]" : "border-white/[0.12] bg-white/[0.02] hover:border-white/25 hover:bg-white/[0.04]", | |
| )} | |
| > | |
| <div className="grid size-16 place-items-center rounded-2xl bg-white/[0.06]"> | |
| <Upload className="size-7 text-white/60" /> | |
| </div> | |
| <div className="text-center"> | |
| <p className="font-semibold text-white/80">Drop your file here or click to browse</p> | |
| <p className="mt-1 text-xs text-white/40">Selectable-text PDF or TXT · Max 20 MB</p> | |
| </div> | |
| <input ref={fileInputRef} type="file" accept={MATERIAL_UPLOAD_ACCEPT} className="hidden" | |
| onChange={e => { const f = e.target.files?.[0]; if (f) handleFile(f); e.target.value = ""; }} /> | |
| </div> | |
| {isUploading && <div className="flex items-center justify-center gap-2 text-sm text-white/60"><LoaderCircle className="size-4 animate-spin" /> Uploading...</div>} | |
| {uploadError && <div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">{uploadError}</div>} | |
| {uploadedDocs.length > 0 && ( | |
| <div className="space-y-2"> | |
| <h3 className="text-xs font-semibold uppercase tracking-[0.12em] text-white/40">Uploaded ({uploadedDocs.length})</h3> | |
| {uploadedDocs.map(doc => ( | |
| <div key={doc.id} className="flex items-center gap-3 rounded-xl border border-white/[0.08] bg-white/[0.03] px-4 py-3"> | |
| <FileText className="size-4 shrink-0 text-white/40" /> | |
| <div className="min-w-0 flex-1"><p className="truncate text-sm font-medium text-white/80">{sourceTitle(doc)}</p><p className="text-xs text-white/40">{doc.file_type || "document"}</p></div> | |
| <button onClick={() => setUploadedDocs(prev => prev.filter(d => d.id !== doc.id))} className="shrink-0 rounded-lg p-1.5 text-white/30 hover:bg-white/[0.06] hover:text-white/60"><X className="size-4" /></button> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {uploadedDocs.length > 0 && ( | |
| <div className="flex flex-col gap-3 sm:flex-row"> | |
| <button onClick={() => setStep("context")} className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-white px-5 py-3 font-semibold text-black transition hover:bg-white/90">Continue <ArrowRight className="size-4" /></button> | |
| <button onClick={handleGenerate} className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-white/[0.12] px-5 py-3 font-semibold text-white/80 transition hover:bg-white/[0.06]"><Sparkles className="size-4" /> Generate — no questions</button> | |
| </div> | |
| )} | |
| <div className="relative my-4"><div className="absolute inset-0 flex items-center"><div className="w-full border-t border-white/[0.06]" /></div><div className="relative flex justify-center text-xs text-white/30"><span className="bg-[#0a0a0a] px-3">or just type a topic</span></div></div> | |
| <button onClick={() => { setContext(prev => ({ ...prev, chapter: "" })); setQuestionStep(0); setStep("context"); }} className="flex items-center justify-center gap-2 rounded-xl border border-white/[0.08] px-5 py-3 text-sm font-medium text-white/60 transition hover:bg-white/[0.04] hover:text-white/80"><BookOpen className="size-4" /> Start with topic name only</button> | |
| </div> | |
| ); | |
| const renderContext = () => { | |
| if (!currentQuestion && allContextCollected) return null; | |
| return ( | |
| <div className="mx-auto flex w-full max-w-lg flex-col gap-6 py-12"> | |
| <button onClick={() => { setQuestionStep(0); setStep("upload"); }} className="flex w-fit items-center gap-1.5 text-xs text-white/40 transition hover:text-white/60"><ArrowLeft className="size-3.5" /> Back</button> | |
| <div className="flex gap-1.5">{missingQuestions.slice(0, 7).map((q, i) => (<div key={q.key} className={cn("h-1 flex-1 rounded-full transition", i < questionStep ? "bg-white/40" : i === questionStep ? "bg-white/70" : "bg-white/[0.08]")} />))}</div> | |
| <div className="space-y-6"> | |
| <h2 className="text-xl font-bold text-white">{currentQuestion?.label || "One more thing..."}</h2> | |
| {currentQuestion?.key === "chapter" && ( | |
| <div className="space-y-3"> | |
| <input autoFocus type="text" placeholder="e.g. Electromagnetic Induction" className="w-full rounded-xl border border-white/[0.12] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20" | |
| onKeyDown={e => { if (e.key === "Enter" && (e.target as HTMLInputElement).value.trim()) handleContextSelect("chapter", (e.target as HTMLInputElement).value.trim()); }} /> | |
| <button onClick={() => { const inp = document.querySelector<HTMLInputElement>('input[placeholder="e.g. Electromagnetic Induction"]'); if (inp?.value.trim()) handleContextSelect("chapter", inp.value.trim()); }} className="w-full rounded-xl bg-white px-4 py-2.5 text-sm font-semibold text-black transition hover:bg-white/90">Next</button> | |
| <button onClick={() => setQuestionStep(s => Math.min(s + 1, missingQuestions.length))} className="w-full text-xs text-white/30 hover:text-white/50">Skip for now</button> | |
| </div> | |
| )} | |
| {currentQuestion?.options && currentQuestion.type !== "multiselect" && ( | |
| <div className="grid grid-cols-2 gap-2 sm:grid-cols-3"> | |
| {(currentQuestion.options as readonly string[] | readonly { value: string; label: string }[]).map(opt => { | |
| const value = typeof opt === "string" ? opt : opt.value; | |
| const label = typeof opt === "string" ? opt : opt.label; | |
| const selected = context[currentQuestion!.key] === value; | |
| return (<button key={value} onClick={() => handleContextSelect(currentQuestion!.key, value)} className={cn("rounded-xl border px-4 py-3 text-sm font-medium transition", selected ? "border-white/30 bg-white/[0.08] text-white" : "border-white/[0.08] text-white/60 hover:border-white/20 hover:text-white/80")}>{label}</button>); | |
| })} | |
| </div> | |
| )} | |
| {currentQuestion?.type === "multiselect" && currentQuestion.options && ( | |
| <div className="space-y-3"> | |
| <div className="grid grid-cols-2 gap-2 sm:grid-cols-3"> | |
| {(currentQuestion.options as readonly { value: string; label: string }[]).map(opt => { | |
| const Icon = OUTPUT_OPTIONS.find(o => o.id === opt.value)?.icon || FileText; | |
| const selected = context.outputs.includes(opt.value); | |
| return (<button key={opt.value} onClick={() => handleOutputToggle(opt.value)} className={cn("flex flex-col items-center gap-2 rounded-xl border px-4 py-4 text-sm font-medium transition", selected ? "border-white/30 bg-white/[0.08] text-white" : "border-white/[0.08] text-white/50 hover:border-white/20 hover:text-white/70")}><Icon className="size-5" /><span>{opt.label}</span>{selected && <Check className="size-3.5 text-white/60" />}</button>); | |
| })} | |
| </div> | |
| <button onClick={() => setQuestionStep(s => Math.min(s + 1, missingQuestions.length))} className="w-full rounded-xl bg-white px-4 py-2.5 text-sm font-semibold text-black transition hover:bg-white/90">{context.outputs.length > 0 ? `Done (${context.outputs.length} selected)` : "Skip — generate all"}</button> | |
| </div> | |
| )} | |
| </div> | |
| <div className="mt-8 rounded-xl border border-white/[0.06] bg-white/[0.02] p-4"> | |
| <p className="mb-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-white/30">Your context</p> | |
| <div className="flex flex-wrap gap-2"> | |
| {context.classLevel && <span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/60">{context.classLevel}</span>} | |
| {context.board && <span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/60">{context.board}</span>} | |
| {context.subject && <span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/60">{context.subject}</span>} | |
| {context.chapter && <span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/60">{context.chapter}</span>} | |
| {context.goal && <span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/60">{GOAL_OPTIONS.find(g => g.value === context.goal)?.label || context.goal}</span>} | |
| {context.availableTime && <span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/60">{TIME_OPTIONS.find(t => t.value === context.availableTime)?.label || context.availableTime}</span>} | |
| {context.outputs.map(o => <span key={o} className="rounded-full border border-white/[0.08] bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/60">{OUTPUT_OPTIONS.find(op => op.id === o)?.label || o}</span>)} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| const renderGenerating = () => ( | |
| <div className="mx-auto flex w-full max-w-lg flex-col items-center gap-6 py-24 text-center"> | |
| <div className="grid size-20 place-items-center rounded-full bg-white/[0.06]"><LoaderCircle className="size-10 animate-spin text-white/60" /></div> | |
| <div><h2 className="text-lg font-bold text-white">Generating your study kit</h2><p className="mt-1 text-sm text-white/50">{genProgress}</p></div> | |
| <div className="flex flex-wrap justify-center gap-2"> | |
| {(context.outputs.length > 0 ? context.outputs : OUTPUT_OPTIONS.map(o => o.id)).map(o => { | |
| const opt = OUTPUT_OPTIONS.find(op => op.id === o); | |
| return <span key={o} className="rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-xs text-white/50">{opt?.label || o}</span>; | |
| })} | |
| </div> | |
| {genError && <div className="mt-4 rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">{genError}</div>} | |
| </div> | |
| ); | |
| const renderResults = () => { | |
| if (!kit) return null; | |
| const sections: Array<{ id: string; label: string; icon: typeof Sparkles; content: React.ReactNode; }> = []; | |
| if (kit.notes) { | |
| sections.push({ | |
| id: "notes", label: "Smart Notes", icon: ClipboardList, | |
| content: ( | |
| <div className="space-y-3 text-sm text-white/70"> | |
| {kit.notes.key_points?.length > 0 && ( | |
| <div className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4"> | |
| <h4 className="mb-2 font-semibold text-white/80">Key Points</h4> | |
| <ul className="list-inside list-disc space-y-1"> | |
| {kit.notes.key_points.slice(0, 8).map((kp: string, i: number) => ( | |
| <li key={i} className="leading-6 text-white/70">{kp}</li> | |
| ))} | |
| </ul> | |
| </div> | |
| )} | |
| {kit.notes.summary && ( | |
| <div className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4"> | |
| <h4 className="mb-2 font-semibold text-white/80">Summary</h4> | |
| <p className="leading-7">{kit.notes.summary}</p> | |
| </div> | |
| )} | |
| {kit.notes.exam_keywords && kit.notes.exam_keywords.length > 0 && ( | |
| <div className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4"> | |
| <h4 className="mb-2 font-semibold text-white/80">Exam Keywords</h4> | |
| <div className="flex flex-wrap gap-1.5"> | |
| {kit.notes.exam_keywords.slice(0, 10).map((kw: string, i: number) => ( | |
| <span key={i} className="rounded-full border border-white/[0.08] bg-white/[0.04] px-2 py-1 text-xs text-white/60">{kw}</span> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {!kit.notes.key_points && !kit.notes.summary && <p className="text-white/50">Notes generated.</p>} | |
| </div> | |
| ), | |
| }); | |
| } | |
| if (kit.quiz) { | |
| sections.push({ | |
| id: "quiz", label: "Practice Quiz", icon: FileQuestion, | |
| content: ( | |
| <div className="space-y-3"> | |
| {(kit.quiz.questions || []).slice(0, 5).map((q, i) => ( | |
| <div key={i} className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4"> | |
| <div className="flex items-start gap-3"> | |
| <span className="grid size-7 shrink-0 place-items-center rounded-lg border border-white/[0.08] bg-black/24 text-xs font-bold text-white/50">{i + 1}</span> | |
| <div><p className="text-sm font-medium text-white/80">{q.question}</p> | |
| <div className="mt-2 grid gap-1.5 sm:grid-cols-2"> | |
| {q.options?.slice(0, 4).map((opt, oi) => ( | |
| <div key={oi} className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2 text-xs text-white/60">{opt}</div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| {(!kit.quiz.questions || kit.quiz.questions.length === 0) && <p className="text-sm text-white/50">Quiz questions will be ready in the full session.</p>} | |
| </div> | |
| ), | |
| }); | |
| } | |
| if (kit.flashcards) { | |
| sections.push({ | |
| id: "flashcards", label: "Flashcards", icon: Layers3, | |
| content: ( | |
| <div className="grid gap-2 sm:grid-cols-2"> | |
| {(kit.flashcards.cards || []).slice(0, 6).map((card: { front: string; back: string }, i: number) => ( | |
| <div key={i} className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4"> | |
| <p className="text-xs font-semibold text-white/40">FRONT</p> | |
| <p className="mt-1 text-sm text-white/80">{card.front}</p> | |
| <p className="mt-2 text-xs font-semibold text-white/40">BACK</p> | |
| <p className="mt-1 text-sm text-white/60">{card.back}</p> | |
| </div> | |
| ))} | |
| {(!kit.flashcards.cards || kit.flashcards.cards.length === 0) && <p className="col-span-2 text-sm text-white/50">Flashcards will be created in the full session.</p>} | |
| </div> | |
| ), | |
| }); | |
| } | |
| if (kit.videoPlan) { | |
| sections.push({ | |
| id: "video", label: "Study Video Plan", icon: Video, | |
| content: ( | |
| <div className="space-y-3"> | |
| <div className="grid gap-3 sm:grid-cols-3"> | |
| <div className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4"> | |
| <p className="text-[10px] font-semibold uppercase text-white/40">Title</p> | |
| <p className="mt-1 text-sm font-medium text-white/80">{kit.videoPlan.title || "Study video"}</p> | |
| </div> | |
| <div className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4"> | |
| <p className="text-[10px] font-semibold uppercase text-white/40">Scenes</p> | |
| <p className="mt-1 text-sm text-white/80">{kit.videoPlan.scenes?.length || 0} beats</p> | |
| </div> | |
| <div className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4"> | |
| <p className="text-[10px] font-semibold uppercase text-white/40">Duration</p> | |
| <p className="mt-1 text-sm text-white/80">{kit.videoPlan.target_duration_range || "~10 min"}</p> | |
| </div> | |
| </div> | |
| {(kit.videoPlan.scenes || []).slice(0, 5).map((scene: { title?: string; duration?: string; narration?: string }, i: number) => ( | |
| <div key={i} className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4"> | |
| <div className="flex items-center gap-3"> | |
| <span className="grid size-7 shrink-0 place-items-center rounded-lg bg-white/[0.06] text-xs font-bold text-white/50">{i + 1}</span> | |
| <div className="min-w-0 flex-1"> | |
| <p className="text-sm font-medium text-white/80">{scene.title || `Scene ${i + 1}`}</p> | |
| {scene.duration && <p className="text-xs text-white/40">{scene.duration}</p>} | |
| </div> | |
| </div> | |
| {scene.narration && <p className="mt-2 text-xs leading-6 text-white/50 line-clamp-2">{scene.narration}</p>} | |
| </div> | |
| ))} | |
| </div> | |
| ), | |
| }); | |
| } | |
| if (kit.pyqAnalysis) { | |
| sections.push({ | |
| id: "pyq", label: "PYQ Pattern Insights", icon: GraduationCap, | |
| content: ( | |
| <div className="space-y-3"> | |
| <div className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4 text-sm leading-7 text-white/70"> | |
| <p>{kit.pyqAnalysis.summary}</p> | |
| </div> | |
| <div className="grid gap-3 sm:grid-cols-2"> | |
| {(kit.pyqAnalysis.repeated_topics || []).slice(0, 5).map((t: { topic: string; chapter: string; count: number }, i: number) => ( | |
| <div key={i} className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3"> | |
| <p className="text-xs font-semibold text-white/40">REPEATED TOPIC</p> | |
| <p className="mt-1 text-sm text-white/80">{t.topic}</p> | |
| {t.count > 0 && <p className="mt-0.5 text-xs text-white/40">Appeared {t.count}×</p>} | |
| </div> | |
| ))} | |
| </div> | |
| {!kit.pyqAnalysis.repeated_topics && <p className="text-sm text-white/50">PYQ analysis uses uploaded papers. Add question papers for deeper pattern detection.</p>} | |
| </div> | |
| ), | |
| }); | |
| } | |
| return ( | |
| <div className="mx-auto flex w-full max-w-2xl flex-col gap-6 py-8"> | |
| <div className="flex items-start justify-between gap-4"> | |
| <div> | |
| <h1 className="text-2xl font-bold text-white">Your Study Kit</h1> | |
| <p className="mt-1 text-sm text-white/50"> | |
| {[context.subject, context.chapter, context.board, context.classLevel].filter(Boolean).join(" · ")} | |
| </p> | |
| <p className="mt-0.5 text-xs text-white/30">Estimated study time: {estimateStudyTime(context)} · {uploadedDocs.length} source{uploadedDocs.length !== 1 ? "s" : ""}</p> | |
| </div> | |
| <button onClick={() => { setKit(null); setStep("upload"); }} className="flex shrink-0 items-center gap-1.5 rounded-xl border border-white/[0.1] px-3.5 py-2 text-xs font-medium text-white/50 transition hover:bg-white/[0.06] hover:text-white/70"><RefreshCw className="size-3.5" /> New kit</button> | |
| </div> | |
| {uploadedDocs.length > 0 && ( | |
| <div className="rounded-xl border border-white/[0.06] bg-white/[0.02] px-4 py-3"> | |
| <p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-white/30">Sources used</p> | |
| <div className="mt-2 flex flex-wrap gap-2"> | |
| {uploadedDocs.map(doc => ( | |
| <span key={doc.id} className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/50"><FileText className="size-3" /> {sourceTitle(doc)}</span> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {sections.map(section => ( | |
| <div key={section.id} className="overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.02]"> | |
| <button onClick={() => setExpandedSection(expandedSection === section.id ? null : section.id)} className="flex w-full items-center justify-between px-5 py-4 text-left transition hover:bg-white/[0.02]"> | |
| <div className="flex items-center gap-3"><section.icon className="size-4 text-white/50" /><span className="font-semibold text-white/80">{section.label}</span></div> | |
| <ChevronDown className={cn("size-4 text-white/30 transition", expandedSection === section.id && "rotate-180")} /> | |
| </button> | |
| {expandedSection === section.id && <div className="border-t border-white/[0.06] px-5 py-4">{section.content}</div>} | |
| </div> | |
| ))} | |
| <div className="rounded-2xl border border-white/[0.08] bg-white/[0.02] p-5"> | |
| <div className="flex items-center gap-2 mb-4"><Moon className="size-4 text-white/50" /><h3 className="font-semibold text-white/80">Revision Checklist</h3></div> | |
| <div className="space-y-3 text-sm text-white/60"> | |
| {["Review summary and key concepts", "Practice with generated quiz questions", "Review flashcards for active recall", "Watch or plan study video", "Review PYQ patterns for exam strategy", "Self-test with closed-book recall"].map((item, i) => ( | |
| <label key={i} className="flex items-start gap-3"><input type="checkbox" className="mt-0.5 size-4 accent-white/60" /><span>{item}</span></label> | |
| ))} | |
| </div> | |
| </div> | |
| <p className="text-center text-[10px] text-white/20">Generated {new Date(kit.generatedAt).toLocaleTimeString()} · Sources: {uploadedDocs.length} · ~{estimateStudyTime(context)}</p> | |
| </div> | |
| ); | |
| }; | |
| return ( | |
| <div className="mx-auto min-h-[calc(100vh-4rem)] w-full max-w-5xl px-4 py-6 sm:px-6 lg:px-8"> | |
| {step === "results" && ( | |
| <div className="mb-6 flex items-center gap-3"> | |
| <button onClick={() => router.push("/home")} className="rounded-xl border border-white/[0.08] p-2 text-white/40 transition hover:bg-white/[0.06] hover:text-white/60"><ArrowLeft className="size-4" /></button> | |
| <div><p className="text-xs font-semibold uppercase tracking-[0.14em] text-white/30">Study Generator</p><p className="text-sm text-white/60">Study kit ready</p></div> | |
| </div> | |
| )} | |
| {step === "upload" && renderUpload()} | |
| {step === "context" && renderContext()} | |
| {step === "generating" && renderGenerating()} | |
| {step === "results" && renderResults()} | |
| </div> | |
| ); | |
| } | |