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 && (
)}
);
}
// ── 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 */}
{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 }) => (
))}
)}
);
}