pushy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-28 11:30:01 +08:00
parent b03b204861
commit bac7168b1e
100 changed files with 5958 additions and 1976 deletions
@@ -0,0 +1,592 @@
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 { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
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 {
fetchQuiz, createQuiz, updateQuiz,
createQuizQuestion, updateQuizQuestion,
course, unit, quiz, loading,
} = useCourses();
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);
const breadcrumbItems = [
{ 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" },
];
// ── Fetch ──────────────────────────────────────────────────────────────────
useEffect(() => {
(async () => {
await fetchQuiz(courseId, unitId);
setInitializing(false);
})();
}, [courseId, unitId]);
// ── Seed ──────────────────────────────────────────────────────────────────
useEffect(() => {
if (!quiz) return;
const t = quiz.title ?? "";
const ps = quiz.passing_score ?? 70;
const ir = quiz.is_required === true || quiz.is_required === 1;
const mq = quiz.max_questions ?? "";
const sq = quiz.shuffle_questions === true || quiz.shuffle_questions === 1;
const qs = (quiz.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 });
}, [quiz]);
// ── 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 = quiz?.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);
quizId = res?.data?.data?.data?.quiz_id;
if (!quizId) return;
} else {
await updateQuiz(courseId, unitId, quizId, meta);
}
for (let i = 0; i < questions.length; i++) {
const q = { ...questions[i], order_index: i, updatedBy: user?.user_id };
if (q.question_id) {
await updateQuizQuestion(courseId, unitId, quizId, q.question_id, q);
} else {
await createQuizQuestion(courseId, unitId, quizId, q);
}
}
initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions });
navigate(-1);
};
// ── 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(-1)}>
<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>
);
}