Files
starr-philproperties/src/modules/admin/pages/courses/units/ModifyQuiz.jsx
T
kennethobsequio b1805cfe4d try to deploy
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-07-13 21:27:56 +08:00

616 lines
24 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="flex gap-1">
<Input
type="number"
min={1}
max={max}
value={val}
onChange={(e) => setVal(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleJump()}
placeholder={`1–${max}`}
className="h-7 text-xs"
/>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 px-2 text-xs shrink-0"
onClick={handleJump}
>
Go
</Button>
</div>
);
}
// ── Question Navigator ────────────────────────────────────────────────────────
const TYPE_LABEL = {
multiple_choice: "MC",
multi_select: "MS",
true_false: "TF",
};
function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs, navContainerRef, errors }) {
return (
<div className="flex flex-col h-full">
<div className="px-3 py-3 border-b shrink-0">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Questions
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{questions.length} total
</p>
</div>
<div ref={navContainerRef} className="flex-1 overflow-y-auto py-2">
{questions.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-8 px-3">
No questions yet.
</p>
) : (
<div className="space-y-0.5 px-2">
{questions.map((q, i) => {
const isActive = i === activeIndex;
const hasError = !!errors?.[i];
const typeLabel = TYPE_LABEL[q.type] ?? "Q";
return (
<div
key={q._tempId ?? q.question_id ?? i}
ref={(el) => (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"
)}
>
<span className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center rounded text-[10px] font-semibold",
isActive
? "bg-primary text-primary-foreground"
: hasError
? "bg-destructive text-destructive-foreground"
: "bg-muted-foreground/20 text-muted-foreground"
)}>
{i + 1}
</span>
<span className="text-[10px] font-medium text-muted-foreground shrink-0 w-5">
{typeLabel}
</span>
<span className="flex-1 text-xs truncate min-w-0">
{q.question?.trim()
? q.question.trim()
: <span className="italic text-muted-foreground">Untitled</span>
}
</span>
<div className="hidden group-hover:flex items-center gap-0.5 shrink-0">
<button
type="button"
onClick={(e) => { e.stopPropagation(); onMove(i, "up"); }}
disabled={i === 0}
className="h-4 w-4 flex items-center justify-center rounded hover:bg-muted-foreground/20 disabled:opacity-30 transition-colors"
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onMove(i, "down"); }}
disabled={i === questions.length - 1}
className="h-4 w-4 flex items-center justify-center rounded hover:bg-muted-foreground/20 disabled:opacity-30 transition-colors"
>
<ChevronDown className="h-3 w-3" />
</button>
</div>
</div>
);
})}
</div>
)}
</div>
{questions.length > 5 && (
<div className="px-3 py-3 border-t shrink-0 space-y-1.5">
<p className="text-xs text-muted-foreground">Jump to question</p>
<JumpToInput max={questions.length} onJump={onJump} />
</div>
)}
</div>
);
}
// ── 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: <House className="size-4" />, 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: <House className="size-4" />, 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 (
<div className="flex flex-col min-h-screen bg-muted/60">
<PageMeta title={unit ? `${unit.title} – Quiz - STARR` : undefined} />
{/* ── Sticky header ── */}
<div
ref={headerRef}
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
style={{ top: "var(--navbar-h)" }}
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`${scopeBase}/view`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
<HelpCircle className="h-5 w-5 text-muted-foreground" />
Unit Quiz
</h1>
<p className="text-sm text-muted-foreground">
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
</p>
</div>
<Button onClick={handleSave} disabled={loading || !isDirty}>
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Save className="h-4 w-4 mr-2" />}
Save Quiz
</Button>
</div>
</div>
</div>
{/* ── Split layout ── */}
<div className="flex flex-1 lg:container lg:mx-auto lg:px-6 px-0 w-full items-start">
{/* LEFT — Navigator */}
<div
className="hidden lg:flex flex-col w-60 shrink-0 border-r bg-background"
style={{
position: "sticky",
top: `calc(var(--navbar-h) + var(--quiz-h, 0px))`,
height: `calc(100vh - var(--navbar-h) - var(--quiz-h, 0px))`,
}}
>
<QuestionNavigator
questions={questions}
activeIndex={activeIndex}
onJump={jumpTo}
onMove={moveQuestion}
navItemRefs={navItemRefs}
navContainerRef={navContainerRef}
errors={errors}
/>
</div>
{/* RIGHT — Content */}
<div className="flex-1 min-w-0 px-4 lg:px-8 py-6">
{initializing ? (
<div className="flex items-center justify-center py-20">
<Spinner className="h-6 w-6" />
</div>
) : (
<div className="max-w-2xl space-y-6 pb-16">
{/* Settings */}
<div className="rounded-lg border bg-card p-6 space-y-5">
<p className="text-sm font-medium">Settings</p>
<div className="space-y-1.5">
<Label>Title</Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Unit Quiz"
/>
</div>
<div className="space-y-1.5 max-w-[200px]">
<Label>Passing Score (%)</Label>
<Input
type="number"
min={0}
max={100}
value={passingScore}
onChange={(e) => setPassingScore(parseInt(e.target.value) || 0)}
/>
</div>
<div className="space-y-1.5">
<Label>
Max Questions{" "}
<span className="text-muted-foreground font-normal text-xs">(blank = show all)</span>
</Label>
<Input
type="number" min={1} max={questions.length || undefined}
value={maxQuestions}
onChange={(e) => setMaxQuestions(e.target.value)}
placeholder={`All (${questions.length})`}
/>
</div>
<div className="flex items-center gap-3">
<Checkbox
id="quiz_required"
checked={isRequired === true}
onCheckedChange={(val) => setIsRequired(val)}
/>
<Label htmlFor="quiz_required" className="cursor-pointer">
Required to proceed to next unit
</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox
id="quiz_shuffle"
checked={shuffleQuestions === true}
onCheckedChange={(val) => setShuffleQuestions(val)}
/>
<Label htmlFor="quiz_shuffle" className="cursor-pointer">
Shuffle question order for each attempt
</Label>
</div>
</div>
{maxQuestions && parseInt(maxQuestions) < questions.length && (
<p className="text-xs text-muted-foreground bg-muted rounded-md px-3 py-2">
Takers will see <strong>{maxQuestions}</strong> randomly selected questions
out of <strong>{questions.length}</strong> in the pool.
</p>
)}
{/* Questions */}
<div className="space-y-4">
{questions.length === 0 ? (
<div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center">
<HelpCircle className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">
No questions yet. Add one below.
</p>
</div>
) : (
questions.map((q, i) => (
<div
key={q._tempId ?? q.question_id ?? i}
ref={(el) => (questionRefs.current[i] = el)}
onClick={() => setActiveIndex(i)}
>
<QuestionCard
question={q}
index={i}
onChange={(updated) => updateQuestion(i, updated)}
onRemove={() => removeQuestion(i)}
error={errors[i]}
/>
</div>
))
)}
</div>
{/* Add question */}
<div className="rounded-lg border bg-card p-4">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium mb-3">
Add Question
</p>
<div className="flex flex-wrap gap-2">
{[
{ type: "multiple_choice", label: "Multiple Choice" },
{ type: "multi_select", label: "Multi Select" },
{ type: "true_false", label: "True / False" },
].map(({ type, label }) => (
<Button
key={type}
type="button"
variant="outline"
size="sm"
onClick={() => addQuestion(type)}
>
<Plus className="h-3.5 w-3.5 mr-1" />
{label}
</Button>
))}
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}