import { useEffect, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { ArrowLeft, House, Plus, Save, HelpCircle, ChevronUp, ChevronDown } from "lucide-react"; import { toast } from "sonner"; import { useCourses } from "@/contexts/AdminCoursesContext"; import { useAuth } from "@/contexts/AuthContext"; import api from "@/utils/api.util"; import { PageMeta } from "@/contexts/MetadataContext"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { Spinner } from "@/components/ui/spinner"; import { cn } from "@/lib/utils"; import { QuestionCard, makeQuestion } from "../../../components/courses/QuestionEditor"; // ── Validation ──────────────────────────────────────────────────────────────── function validate(questions) { const errors = {}; questions.forEach((q, i) => { const qErr = {}; if (!q.question?.trim()) qErr.question = "Question text is required."; const correctCount = q.options.filter((o) => o.is_correct).length; if (correctCount === 0) qErr.options = "At least one correct answer is required."; if (q.options.some((o) => !o.text?.trim())) qErr.options = "All option texts are required."; if (Object.keys(qErr).length) errors[i] = qErr; }); return errors; } // ── Dirty-check snapshot (stable fields only, strips internal _tempId) ──────── function snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions }) { return JSON.stringify({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions: questions.map((q) => ({ question_id: q.question_id ?? null, question: q.question, type: q.type, points: q.points ?? 1, options: (q.options ?? []).map((o) => ({ option_id: o.option_id ?? null, text: o.text, is_correct: o.is_correct, })), })), }); } // ── Jump to input ───────────────────────────────────────────────────────────── function JumpToInput({ max, onJump }) { const [val, setVal] = useState(""); const handleJump = () => { const n = parseInt(val, 10); if (!isNaN(n) && n >= 1 && n <= max) { onJump(n - 1); setVal(""); } }; return (
setVal(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleJump()} placeholder={`1–${max}`} className="h-7 text-xs" />
); } // ── Question Navigator ──────────────────────────────────────────────────────── const TYPE_LABEL = { multiple_choice: "MC", multi_select: "MS", true_false: "TF", }; function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs, navContainerRef, errors }) { return (

Questions

{questions.length} total

{questions.length === 0 ? (

No questions yet.

) : (
{questions.map((q, i) => { const isActive = i === activeIndex; const hasError = !!errors?.[i]; const typeLabel = TYPE_LABEL[q.type] ?? "Q"; return (
(navItemRefs.current[i] = el)} onClick={() => onJump(i)} className={cn( "group flex items-center gap-1.5 rounded-md px-2 py-1.5 cursor-pointer transition-colors", isActive ? "bg-primary/10 text-primary" : hasError ? "bg-destructive/10 text-destructive hover:bg-destructive/20" : "hover:bg-muted text-foreground" )} > {i + 1} {typeLabel} {q.question?.trim() ? q.question.trim() : Untitled }
); })}
)}
{questions.length > 5 && (

Jump to question

)}
); } // ── Main Page ───────────────────────────────────────────────────────────────── export default function ModifyQuiz() { const navigate = useNavigate(); const { courseId, unitId } = useParams(); const { createQuiz, updateQuiz, bulkSyncQuizQuestions, course, unit, loading, } = useCourses(); const [localQuiz, setLocalQuiz] = useState(null); const { user } = useAuth(); const [initializing, setInitializing] = useState(true); const [questions, setQuestions] = useState([]); const [errors, setErrors] = useState({}); const [activeIndex, setActiveIndex] = useState(0); const [title, setTitle] = useState(""); const [passingScore, setPassingScore] = useState(70); const [isRequired, setIsRequired] = useState(false); const [maxQuestions, setMaxQuestions] = useState(""); const [shuffleQuestions, setShuffleQuestions] = useState(false); const questionRefs = useRef([]); const navItemRefs = useRef([]); const navContainerRef = useRef(null); const headerRef = useRef(null); const initialSnapshot = useRef(null); // Junction revamp — this builder runs course-scoped AND from the standalone // Unit Library (/admin/units/:unitId/quiz/edit, no :courseId param). const scopeBase = courseId ? `/admin/courses/${courseId}/units/${unitId}` : `/admin/units/${unitId}`; const breadcrumbItems = courseId ? [ { label: "Home", icon: , to: "/admin" }, { label: "Courses", to: "/admin/courses" }, { label: course?.title ?? "…", to: `/admin/courses/${courseId}` }, { label: unit?.title ?? "…", to: `/admin/courses/${courseId}/units/${unitId}` }, { label: "Quiz" }, ] : [ { label: "Home", icon: , to: "/admin" }, { label: "Units Library", to: "/admin/units" }, { label: unit?.title ?? "…", to: `/admin/units/${unitId}/view` }, { label: "Quiz" }, ]; // ── Fetch — silently treat 404 as "no quiz yet" (create mode) ───────────── useEffect(() => { (async () => { try { const { data } = await api.get(`${scopeBase}/quiz`); const result = data?.data?.data ?? null; setLocalQuiz(result); } catch (err) { if (err?.response?.status !== 404) { toast(err?.response?.data?.message ?? "Could not load quiz."); } // 404 → no quiz yet, stay in create mode with localQuiz = null } finally { setInitializing(false); } })(); }, [courseId, unitId]); // ── Seed form from fetched quiz ──────────────────────────────────────────── useEffect(() => { if (!localQuiz) return; const t = localQuiz.title ?? ""; const ps = localQuiz.passing_score ?? 70; const ir = localQuiz.is_required === true || localQuiz.is_required === 1; const mq = localQuiz.max_questions ?? ""; const sq = localQuiz.shuffle_questions === true || localQuiz.shuffle_questions === 1; const qs = (localQuiz.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] })); setTitle(t); setPassingScore(ps); setIsRequired(ir); setMaxQuestions(mq); setShuffleQuestions(sq); setQuestions(qs); initialSnapshot.current = snapQuiz({ title: t, passingScore: ps, isRequired: ir, maxQuestions: mq, shuffleQuestions: sq, questions: qs }); }, [localQuiz]); // ── Measure sticky header → --quiz-h ────────────────────────────────────── useEffect(() => { if (!headerRef.current) return; const update = () => { document.documentElement.style.setProperty( "--quiz-h", `${headerRef.current.offsetHeight}px` ); }; update(); window.addEventListener("resize", update); return () => window.removeEventListener("resize", update); }, []); // ── Scroll active nav item into view ────────────────────────────────────── useEffect(() => { const item = navItemRefs.current[activeIndex]; const container = navContainerRef.current; if (!item || !container) return; const itemTop = item.offsetTop; const itemBottom = itemTop + item.offsetHeight; const viewTop = container.scrollTop; const viewBottom = viewTop + container.clientHeight; if (itemTop < viewTop) { container.scrollTop = itemTop; } else if (itemBottom > viewBottom) { container.scrollTop = itemBottom - container.clientHeight; } }, [activeIndex]); // ── IntersectionObserver — highlight nav as user scrolls ────────────────── useEffect(() => { if (!questions.length) return; const observers = []; questionRefs.current.forEach((el, i) => { if (!el) return; const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) setActiveIndex(i); }, { rootMargin: "-20% 0px -70% 0px", threshold: 0 } ); observer.observe(el); observers.push(observer); }); return () => observers.forEach((o) => o.disconnect()); }, [questions.length]); // ── Scroll helper with sticky offset ────────────────────────────────────── const scrollToQuestion = (index) => { const el = questionRefs.current[index]; if (!el) return; const navbarH = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--navbar-h") || "0", 10); const quizH = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--quiz-h") || "0", 10); const offset = navbarH + quizH + 16; const top = el.getBoundingClientRect().top + window.scrollY - offset; window.scrollTo({ top, behavior: "smooth" }); }; // ── Question actions ─────────────────────────────────────────────────────── const addQuestion = (type = "multiple_choice") => { setQuestions((prev) => { const next = [...prev, { ...makeQuestion(type), order_index: prev.length }]; setTimeout(() => { const idx = next.length - 1; setActiveIndex(idx); scrollToQuestion(idx); }, 50); return next; }); }; const updateQuestion = (index, updated) => { setQuestions((prev) => prev.map((q, i) => i === index ? updated : q)); setErrors((prev) => { const e = { ...prev }; delete e[index]; return e; }); }; const removeQuestion = (index) => { setQuestions((prev) => prev.filter((_, i) => i !== index)); setActiveIndex((prev) => Math.max(0, prev >= index ? prev - 1 : prev)); }; const moveQuestion = (index, direction) => { setQuestions((prev) => { const next = [...prev]; const swapIndex = direction === "up" ? index - 1 : index + 1; if (swapIndex < 0 || swapIndex >= next.length) return prev; [next[index], next[swapIndex]] = [next[swapIndex], next[index]]; return next; }); const newIndex = direction === "up" ? index - 1 : index + 1; setActiveIndex(newIndex); setTimeout(() => scrollToQuestion(newIndex), 50); }; const jumpTo = (index) => { setActiveIndex(index); scrollToQuestion(index); }; const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0); // ── Dirty tracking ───────────────────────────────────────────────────────── const isDirty = initialSnapshot.current === null ? questions.length > 0 // new quiz — enable once they've added a question : snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions }) !== initialSnapshot.current; // ── Save ─────────────────────────────────────────────────────────────────── const handleSave = async () => { const errs = validate(questions); if (Object.keys(errs).length) { setErrors(errs); jumpTo(parseInt(Object.keys(errs)[0], 10)); return; } let quizId = localQuiz?.quiz_id; const meta = { title: title || "Unit Quiz", passing_score: passingScore, is_required: isRequired, max_questions: maxQuestions ? parseInt(maxQuestions) : null, shuffle_questions: shuffleQuestions, updatedBy: user?.user_id, createdBy: user?.user_id, }; if (!quizId) { const res = await createQuiz(courseId, unitId, meta); // res is the raw response body: { status, message, data: { data: quiz } } // (createQuiz() itself unwraps the same object as data?.data?.data to get // the quiz row — this needs the same two levels, not three). quizId = res?.data?.data?.quiz_id; if (!quizId) return; setLocalQuiz((prev) => ({ ...prev, quiz_id: quizId })); } else { await updateQuiz(courseId, unitId, quizId, meta); } await bulkSyncQuizQuestions(courseId, unitId, quizId, questions, user?.user_id); initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions }); navigate(`${scopeBase}/view`); }; // ── Render ───────────────────────────────────────────────────────────────── return (
{/* ── Sticky header ── */}

Unit Quiz

{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}

{/* ── Split layout ── */}
{/* LEFT — Navigator */}
{/* RIGHT — Content */}
{initializing ? (
) : (
{/* Settings */}

Settings

setTitle(e.target.value)} placeholder="Unit Quiz" />
setPassingScore(parseInt(e.target.value) || 0)} />
setMaxQuestions(e.target.value)} placeholder={`All (${questions.length})`} />
setIsRequired(val)} />
setShuffleQuestions(val)} />
{maxQuestions && parseInt(maxQuestions) < questions.length && (

Takers will see {maxQuestions} randomly selected questions out of {questions.length} in the pool.

)} {/* Questions */}
{questions.length === 0 ? (

No questions yet. Add one below.

) : ( questions.map((q, i) => (
(questionRefs.current[i] = el)} onClick={() => setActiveIndex(i)} > updateQuestion(i, updated)} onRemove={() => removeQuestion(i)} error={errors[i]} />
)) )}
{/* Add question */}

Add Question

{[ { type: "multiple_choice", label: "Multiple Choice" }, { type: "multi_select", label: "Multi Select" }, { type: "true_false", label: "True / False" }, ].map(({ type, label }) => ( ))}
)}
); }