added draft for quiz and assessment unfinish

This commit is contained in:
rgrgogu
2026-07-20 03:56:36 +08:00
parent 84311e632b
commit 3f12a61422
2 changed files with 197 additions and 25 deletions
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, Plus, Save, ClipboardList, ChevronUp, ChevronDown, AlertTriangle } from "lucide-react";
import { ArrowLeft, House, Plus, Save, ClipboardList, ChevronUp, ChevronDown, AlertTriangle, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { useCourses } from "@/contexts/AdminCoursesContext";
@@ -34,6 +34,22 @@ function snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestio
});
}
// ── Server row → form state ──────────────────────────────────────────────────
function toFormState(a) {
return {
title: a?.title ?? "",
passingScore: a?.passing_score ?? 70,
timeLimit: a?.time_limit_minutes ?? "",
isRequired: a?.is_required === true || a?.is_required === 1,
maxQuestions: a?.max_questions ?? "",
maxAttempts: a?.max_attempts ?? 3,
cooldownHours: a?.cooldown_hours ?? 24,
shuffleQuestions: a?.shuffle_questions === true || a?.shuffle_questions === 1,
questions: (a?.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] })),
};
}
// ── Validation ────────────────────────────────────────────────────────────────
function validate(questions) {
@@ -213,6 +229,9 @@ export default function CourseAssessment() {
const [localAssessment, setLocalAssessment] = useState(null);
const { user } = useAuth();
const DRAFT_KEY = `draftAssessment_${courseId}`;
const [draftInfo, setDraftInfo] = useState(null); // { savedAt } — non-null while an unsaved local draft exists
const [initializing, setInitializing] = useState(true);
const [questions, setQuestions] = useState([]);
const [errors, setErrors] = useState({});
@@ -260,21 +279,39 @@ export default function CourseAssessment() {
// ── Seed ──────────────────────────────────────────────────────────────────
useEffect(() => {
if (!localAssessment) return;
const t = localAssessment.title ?? "";
const ps = localAssessment.passing_score ?? 70;
const tl = localAssessment.time_limit_minutes ?? "";
const ir = localAssessment.is_required === true || localAssessment.is_required === 1;
const mq = localAssessment.max_questions ?? "";
const ma = localAssessment.max_attempts ?? 3;
const ch = localAssessment.cooldown_hours ?? 24;
const sq = localAssessment.shuffle_questions === true || localAssessment.shuffle_questions === 1;
const qs = (localAssessment.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
setTitle(t); setPassingScore(ps); setTimeLimit(tl); setIsRequired(ir);
setMaxQuestions(mq); setMaxAttempts(ma); setCooldownHours(ch); setShuffleQuestions(sq); setQuestions(qs);
initialSnapshot.current = snapAssessment({ title: t, passingScore: ps, timeLimit: tl, isRequired: ir, maxQuestions: mq, maxAttempts: ma, cooldownHours: ch, shuffleQuestions: sq, questions: qs });
originalQuestionIdsRef.current = qs.map((q) => q.question_id).filter(Boolean);
const s = toFormState(localAssessment);
setTitle(s.title); setPassingScore(s.passingScore); setTimeLimit(s.timeLimit); setIsRequired(s.isRequired);
setMaxQuestions(s.maxQuestions); setMaxAttempts(s.maxAttempts); setCooldownHours(s.cooldownHours);
setShuffleQuestions(s.shuffleQuestions); setQuestions(s.questions);
initialSnapshot.current = snapAssessment(s);
originalQuestionIdsRef.current = s.questions.map((q) => q.question_id).filter(Boolean);
}, [localAssessment]);
// ── Restore an unsaved local draft (survives a reload before Save Assessment) ─
useEffect(() => {
if (initializing) return;
const raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return;
try {
const draft = JSON.parse(raw);
if (!draft?.questions) return;
setTitle(draft.title ?? "");
setPassingScore(draft.passingScore ?? 70);
setTimeLimit(draft.timeLimit ?? "");
setIsRequired(draft.isRequired ?? false);
setMaxQuestions(draft.maxQuestions ?? "");
setMaxAttempts(draft.maxAttempts ?? 3);
setCooldownHours(draft.cooldownHours ?? 24);
setShuffleQuestions(draft.shuffleQuestions ?? false);
setQuestions(draft.questions ?? []);
setDraftInfo({ savedAt: draft.savedAt });
toast("Restored an unsaved draft from this browser.");
} catch {
localStorage.removeItem(DRAFT_KEY);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initializing]);
// ── Measure sticky header → --assessment-h ────────────────────────────────
useEffect(() => {
if (!headerRef.current) return;
@@ -384,6 +421,38 @@ export default function CourseAssessment() {
? questions.length > 0
: snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions }) !== initialSnapshot.current;
// ── Auto-save unsaved changes to localStorage as a draft ───────────────────
useEffect(() => {
if (initializing) return;
const timer = setTimeout(() => {
if (isDirty) {
const payload = { title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions, savedAt: Date.now() };
try {
localStorage.setItem(DRAFT_KEY, JSON.stringify(payload));
setDraftInfo({ savedAt: payload.savedAt });
} catch {
// localStorage full/unavailable — draft persistence is best-effort only.
}
} else {
localStorage.removeItem(DRAFT_KEY);
setDraftInfo(null);
}
}, 400);
return () => clearTimeout(timer);
}, [initializing, isDirty, title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions, DRAFT_KEY]);
// ── Clear draft — discards the local draft and reverts to the last loaded state ─
const handleClearDraft = () => {
localStorage.removeItem(DRAFT_KEY);
setDraftInfo(null);
const s = toFormState(localAssessment);
setTitle(s.title); setPassingScore(s.passingScore); setTimeLimit(s.timeLimit); setIsRequired(s.isRequired);
setMaxQuestions(s.maxQuestions); setMaxAttempts(s.maxAttempts); setCooldownHours(s.cooldownHours);
setShuffleQuestions(s.shuffleQuestions); setQuestions(s.questions);
setErrors({});
toast("Draft cleared.");
};
// ── Save ───────────────────────────────────────────────────────────────────
const handleSave = async () => {
const errs = validate(questions);
@@ -456,6 +525,8 @@ export default function CourseAssessment() {
await bulkSyncAssessmentQuestions(courseId, id, questions, user?.user_id, wipesPool);
initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions });
localStorage.removeItem(DRAFT_KEY);
setDraftInfo(null);
};
const handleConfirmSave = async () => {
@@ -478,7 +549,7 @@ export default function CourseAssessment() {
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">
<div className="flex items-center gap-2 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/courses")}>
<ArrowLeft className="h-4 w-4" />
</Button>
@@ -491,6 +562,22 @@ export default function CourseAssessment() {
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
</p>
</div>
{draftInfo && (
<div className="hidden sm:flex items-center gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5 text-sm">
<span className="size-2 rounded-full bg-amber-500" />
Draft saved {new Date(draftInfo.savedAt).toLocaleTimeString()}
</span>
<Button
type="button"
variant="outline"
onClick={handleClearDraft}
>
<Trash2 />
Clear draft
</Button>
</div>
)}
<Button onClick={handleSave} disabled={loading || confirmLoading || !isDirty}>
{(loading || confirmLoading) ? <Spinner className="h-4 w-4 mr-2" /> : <Save className="h-4 w-4 mr-2" />}
Save Assessment
@@ -1,6 +1,6 @@
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 { ArrowLeft, House, Plus, Save, HelpCircle, ChevronUp, ChevronDown, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { useCourses } from "@/contexts/AdminCoursesContext";
@@ -49,6 +49,19 @@ function snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuesti
});
}
// ── Server row → form state ──────────────────────────────────────────────────
function toFormState(q) {
return {
title: q?.title ?? "",
passingScore: q?.passing_score ?? 70,
isRequired: q?.is_required === true || q?.is_required === 1,
maxQuestions: q?.max_questions ?? "",
shuffleQuestions: q?.shuffle_questions === true || q?.shuffle_questions === 1,
questions: (q?.questions ?? []).map((qq) => ({ ...qq, _tempId: qq.question_id, options: qq.options ?? [] })),
};
}
// ── Jump to input ─────────────────────────────────────────────────────────────
function JumpToInput({ max, onJump }) {
@@ -206,6 +219,9 @@ export default function ModifyQuiz() {
const [localQuiz, setLocalQuiz] = useState(null);
const { user } = useAuth();
const DRAFT_KEY = `draftQuiz_${courseId ?? "lib"}_${unitId}`;
const [draftInfo, setDraftInfo] = useState(null); // { savedAt } — non-null while an unsaved local draft exists
const [initializing, setInitializing] = useState(true);
const [questions, setQuestions] = useState([]);
const [errors, setErrors] = useState({});
@@ -266,17 +282,35 @@ export default function ModifyQuiz() {
// ── 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 });
originalQuestionIdsRef.current = qs.map((q) => q.question_id).filter(Boolean);
const s = toFormState(localQuiz);
setTitle(s.title); setPassingScore(s.passingScore); setIsRequired(s.isRequired);
setMaxQuestions(s.maxQuestions); setShuffleQuestions(s.shuffleQuestions); setQuestions(s.questions);
initialSnapshot.current = snapQuiz(s);
originalQuestionIdsRef.current = s.questions.map((q) => q.question_id).filter(Boolean);
}, [localQuiz]);
// ── Restore an unsaved local draft (survives a reload before Save Quiz) ────
useEffect(() => {
if (initializing) return;
const raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return;
try {
const draft = JSON.parse(raw);
if (!draft?.questions) return;
setTitle(draft.title ?? "");
setPassingScore(draft.passingScore ?? 70);
setIsRequired(draft.isRequired ?? false);
setMaxQuestions(draft.maxQuestions ?? "");
setShuffleQuestions(draft.shuffleQuestions ?? false);
setQuestions(draft.questions ?? []);
setDraftInfo({ savedAt: draft.savedAt });
toast("Restored an unsaved draft from this browser.");
} catch {
localStorage.removeItem(DRAFT_KEY);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initializing]);
// ── Measure sticky header → --quiz-h ──────────────────────────────────────
useEffect(() => {
if (!headerRef.current) return;
@@ -386,6 +420,37 @@ export default function ModifyQuiz() {
? questions.length > 0 // new quiz — enable once they've added a question
: snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions }) !== initialSnapshot.current;
// ── Auto-save unsaved changes to localStorage as a draft ───────────────────
useEffect(() => {
if (initializing) return;
const timer = setTimeout(() => {
if (isDirty) {
const payload = { title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions, savedAt: Date.now() };
try {
localStorage.setItem(DRAFT_KEY, JSON.stringify(payload));
setDraftInfo({ savedAt: payload.savedAt });
} catch {
// localStorage full/unavailable — draft persistence is best-effort only.
}
} else {
localStorage.removeItem(DRAFT_KEY);
setDraftInfo(null);
}
}, 400);
return () => clearTimeout(timer);
}, [initializing, isDirty, title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions, DRAFT_KEY]);
// ── Clear draft — discards the local draft and reverts to the last loaded state ─
const handleClearDraft = () => {
localStorage.removeItem(DRAFT_KEY);
setDraftInfo(null);
const s = toFormState(localQuiz);
setTitle(s.title); setPassingScore(s.passingScore); setIsRequired(s.isRequired);
setMaxQuestions(s.maxQuestions); setShuffleQuestions(s.shuffleQuestions); setQuestions(s.questions);
setErrors({});
toast("Draft cleared.");
};
// ── Save ───────────────────────────────────────────────────────────────────
const handleSave = async () => {
const errs = validate(questions);
@@ -432,6 +497,8 @@ export default function ModifyQuiz() {
await bulkSyncQuizQuestions(courseId, unitId, quizId, questions, user?.user_id, wipesPool);
initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions });
localStorage.removeItem(DRAFT_KEY);
setDraftInfo(null);
navigate(`${scopeBase}/view`);
};
@@ -460,6 +527,24 @@ export default function ModifyQuiz() {
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
</p>
</div>
{draftInfo && (
<div className="hidden sm:flex items-center gap-2 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
Draft saved {new Date(draftInfo.savedAt).toLocaleTimeString()}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground hover:text-destructive"
onClick={handleClearDraft}
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
Clear draft
</Button>
</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