change things

This commit is contained in:
rgrgogu
2026-08-03 13:31:51 +08:00
parent 6be0c29850
commit 8dfa54a731
10 changed files with 420 additions and 385 deletions
@@ -1,19 +1,17 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { Plus, Save, ClipboardList, ChevronUp, ChevronDown, AlertTriangle, Trash2, X } from "lucide-react";
import { ArrowLeft, House, Plus, Save, ClipboardList, ChevronUp, ChevronDown, AlertTriangle, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useCourses } from "@/contexts/AdminCoursesContext"; import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard"; import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { QuestionCard, makeQuestion } from "../../components/courses/QuestionEditor"; import { QuestionCard, makeQuestion } from "./QuestionEditor";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
// ── Dirty-check snapshot ────────────────────────────────────────────────────── // ── Dirty-check snapshot ──────────────────────────────────────────────────────
@@ -114,7 +112,7 @@ const TYPE_LABEL = {
function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs, navContainerRef, errors }) { function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs, navContainerRef, errors }) {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col rounded-lg border bg-card overflow-hidden">
{/* Header */} {/* Header */}
<div className="px-3 py-3 border-b shrink-0"> <div className="px-3 py-3 border-b shrink-0">
@@ -127,7 +125,7 @@ function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs
</div> </div>
{/* Scrollable list */} {/* Scrollable list */}
<div ref={navContainerRef} className="flex-1 overflow-y-auto py-2"> <div ref={navContainerRef} className="max-h-96 overflow-y-auto py-2">
{questions.length === 0 ? ( {questions.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-8 px-3"> <p className="text-xs text-muted-foreground text-center py-8 px-3">
No questions yet. No questions yet.
@@ -216,15 +214,13 @@ function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs
); );
} }
// ── Main Page ───────────────────────────────────────────────────────────────── // ── Main ──────────────────────────────────────────────────────────────────────
export default function CourseAssessment() { export default function AssessmentEditor({ courseId, onSaved, onCancel }) {
const navigate = useNavigate();
const { courseId } = useParams();
const { const {
createAssessment, updateAssessment, createAssessment, updateAssessment,
bulkSyncAssessmentQuestions, bulkSyncAssessmentQuestions,
course, loading, loading,
} = useCourses(); } = useCourses();
const [localAssessment, setLocalAssessment] = useState(null); const [localAssessment, setLocalAssessment] = useState(null);
@@ -258,7 +254,6 @@ export default function CourseAssessment() {
const questionRefs = useRef([]); const questionRefs = useRef([]);
const navItemRefs = useRef([]); const navItemRefs = useRef([]);
const navContainerRef = useRef(null); const navContainerRef = useRef(null);
const headerRef = useRef(null);
// ── Fetch — silently treat 404 as "no assessment yet" (create mode) ───────── // ── Fetch — silently treat 404 as "no assessment yet" (create mode) ─────────
useEffect(() => { useEffect(() => {
@@ -313,20 +308,6 @@ export default function CourseAssessment() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [initializing]); }, [initializing]);
// ── Measure sticky header → --assessment-h ────────────────────────────────
useEffect(() => {
if (!headerRef.current) return;
const update = () => {
document.documentElement.style.setProperty(
"--assessment-h",
`${headerRef.current.offsetHeight}px`
);
};
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, []);
// ── Scroll to keep active nav item visible ───────────────────────────────── // ── Scroll to keep active nav item visible ─────────────────────────────────
useEffect(() => { useEffect(() => {
const item = navItemRefs.current[activeIndex]; const item = navItemRefs.current[activeIndex];
@@ -363,15 +344,11 @@ export default function CourseAssessment() {
return () => observers.forEach((o) => o.disconnect()); return () => observers.forEach((o) => o.disconnect());
}, [questions.length]); }, [questions.length]);
// ── Scroll helper with sticky offset ────────────────────────────────────── // ── Scroll helper — a generous scroll-margin-top on each question keeps it
// clear of the page's sticky navbar/tab-bar above without needing to
// measure their heights.
const scrollToQuestion = (index) => { const scrollToQuestion = (index) => {
const el = questionRefs.current[index]; questionRefs.current[index]?.scrollIntoView({ behavior: "smooth", block: "start" });
if (!el) return;
const navbarH = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--navbar-h") || "0", 10);
const assessmentH = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--assessment-h") || "0", 10);
const offset = navbarH + assessmentH + 16;
const top = el.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top, behavior: "smooth" });
}; };
// ── Question actions ─────────────────────────────────────────────────────── // ── Question actions ───────────────────────────────────────────────────────
@@ -530,6 +507,7 @@ export default function CourseAssessment() {
initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions }); initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions });
localStorage.removeItem(DRAFT_KEY); localStorage.removeItem(DRAFT_KEY);
setDraftInfo(null); setDraftInfo(null);
onSaved?.();
}; };
const handleConfirmSave = async () => { const handleConfirmSave = async () => {
@@ -542,26 +520,16 @@ export default function CourseAssessment() {
// ── Render ───────────────────────────────────────────────────────────────── // ── Render ─────────────────────────────────────────────────────────────────
return ( return (
<div className="flex flex-col min-h-screen bg-muted/60"> <div className="space-y-4">
<PageMeta title={course ? `${course.title} – Assessment - STARR` : undefined} />
{/* ── Sticky header ── */} {/* ── Toolbar ── */}
<div <div className="flex items-center gap-2 rounded-lg border bg-card px-4 py-3">
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-2 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/courses")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2"> <p className="text-sm font-semibold flex items-center gap-1.5">
<ClipboardList className="h-5 w-5 text-muted-foreground" /> <ClipboardList className="h-4 w-4 text-muted-foreground" />
Course Assessment {localAssessment ? "Modify Assessment" : "Create Assessment"}
</h1> </p>
<p className="text-sm text-muted-foreground"> <p className="text-xs text-muted-foreground">
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""} {questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
</p> </p>
</div> </div>
@@ -571,36 +539,29 @@ export default function CourseAssessment() {
<span className="size-2 rounded-full bg-amber-500" /> <span className="size-2 rounded-full bg-amber-500" />
Draft saved {new Date(draftInfo.savedAt).toLocaleTimeString()} Draft saved {new Date(draftInfo.savedAt).toLocaleTimeString()}
</span> </span>
<Button <Button type="button" variant="outline" size="sm" onClick={handleClearDraft}>
type="button" <Trash2 className="h-3.5 w-3.5" />
variant="outline"
onClick={handleClearDraft}
>
<Trash2 />
Clear draft Clear draft
</Button> </Button>
</div> </div>
)} )}
{onCancel && (
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
<X className="h-3.5 w-3.5 mr-1" />
Cancel
</Button>
)}
<Button onClick={handleSave} disabled={loading || confirmLoading || !isDirty}> <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" />} {(loading || confirmLoading) ? <Spinner className="h-4 w-4 mr-2" /> : <Save className="h-4 w-4 mr-2" />}
Save Assessment Save Assessment
</Button> </Button>
</div> </div>
</div>
</div>
{/* ── Split layout ── */} {/* ── Split layout ── */}
<div className="flex flex-1 lg:container lg:mx-auto lg:px-6 px-0 w-full items-start"> <div className="flex flex-col lg:flex-row gap-6 items-start">
{/* LEFT — Navigator (desktop only) */} {/* LEFT — Navigator (desktop only) */}
<div <div className="hidden lg:block w-64 shrink-0">
className="hidden lg:flex flex-col w-60 shrink-0 border-r bg-background"
style={{
position: "sticky",
top: `calc(var(--navbar-h) + var(--assessment-h, 0px))`,
height: `calc(100vh - var(--navbar-h) - var(--assessment-h, 0px))`,
}}
>
<QuestionNavigator <QuestionNavigator
questions={questions} questions={questions}
activeIndex={activeIndex} activeIndex={activeIndex}
@@ -613,13 +574,13 @@ export default function CourseAssessment() {
</div> </div>
{/* RIGHT — Main content */} {/* RIGHT — Main content */}
<div className="flex-1 min-w-0 px-4 lg:px-8 py-6"> <div className="flex-1 min-w-0 w-full">
{initializing ? ( {initializing ? (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<Spinner className="h-6 w-6" /> <Spinner className="h-6 w-6" />
</div> </div>
) : ( ) : (
<div className="max-w-5xl space-y-6 pb-16"> <div className="space-y-6 pb-16">
{/* ── Settings ── */} {/* ── Settings ── */}
<div className="rounded-lg border bg-card p-6 space-y-5"> <div className="rounded-lg border bg-card p-6 space-y-5">
@@ -740,6 +701,7 @@ export default function CourseAssessment() {
<div <div
key={q._tempId ?? q.question_id ?? i} key={q._tempId ?? q.question_id ?? i}
ref={(el) => (questionRefs.current[i] = el)} ref={(el) => (questionRefs.current[i] = el)}
style={{ scrollMarginTop: "calc(var(--navbar-h, 64px) + 180px)" }}
onClick={() => setActiveIndex(i)} onClick={() => setActiveIndex(i)}
> >
<QuestionCard <QuestionCard
@@ -1,7 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { import {
ArrowLeft, ClipboardList, NotebookPen, ClipboardList, NotebookPen,
CheckCircle2, Circle, Users, Activity, CheckCircle2, Circle, Users, Activity,
ChevronDown, ChevronUp, ChevronDown, ChevronUp,
} from "lucide-react"; } from "lucide-react";
@@ -10,7 +9,6 @@ import { toast } from "sonner";
import { useCourses } from "@/contexts/AdminCoursesContext"; import { useCourses } from "@/contexts/AdminCoursesContext";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { useDateFormat } from "@/hooks/useDateFormat"; import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@@ -327,7 +325,7 @@ function LoadingSkeleton() {
); );
} }
// ─── Page ───────────────────────────────────────────────────────────────────── // ─── Main ──────────────────────────────────────────────────────────────────────
const TABS = [ const TABS = [
{ key: "questions", label: "Questions", icon: ClipboardList }, { key: "questions", label: "Questions", icon: ClipboardList },
@@ -335,10 +333,7 @@ const TABS = [
{ key: "sessions", label: "Sessions", icon: Activity }, { key: "sessions", label: "Sessions", icon: Activity },
]; ];
export default function ViewAssessment() { export default function AssessmentOverview({ courseId, onModify }) {
const navigate = useNavigate();
const { courseId } = useParams();
const { const {
fetchAssessmentCompletions, fetchAssessmentSessions, fetchAssessmentCompletions, fetchAssessmentSessions,
completions, sessions, completions, sessions,
@@ -346,10 +341,12 @@ export default function ViewAssessment() {
} = useCourses(); } = useCourses();
const [localAssessment, setLocalAssessment] = useState(null); const [localAssessment, setLocalAssessment] = useState(null);
const [initializing, setInitializing] = useState(true);
const [activeTab, setActiveTab] = useState("questions"); const [activeTab, setActiveTab] = useState("questions");
useEffect(() => { useEffect(() => {
(async () => { (async () => {
setInitializing(true);
try { try {
const { data } = await api.get(`/admin/courses/${courseId}/assessment`); const { data } = await api.get(`/admin/courses/${courseId}/assessment`);
setLocalAssessment(data?.data?.data ?? null); setLocalAssessment(data?.data?.data ?? null);
@@ -357,6 +354,8 @@ export default function ViewAssessment() {
if (err?.response?.status !== 404) { if (err?.response?.status !== 404) {
toast(err?.response?.data?.message ?? "Could not load assessment."); toast(err?.response?.data?.message ?? "Could not load assessment.");
} }
} finally {
setInitializing(false);
} }
})(); })();
}, [courseId]); }, [courseId]);
@@ -372,39 +371,32 @@ export default function ViewAssessment() {
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0); const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
return ( return (
<div className="flex flex-col min-h-screen bg-muted/60"> <div className="space-y-5">
<PageMeta title="View Assessment - STARR" />
{/* ── Header ── */} {/* ── Toolbar ── */}
<div <div className="flex items-center gap-2 rounded-lg border bg-card px-4 py-3">
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("/admin/courses")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2"> <p className="text-sm font-semibold flex items-center gap-1.5">
<ClipboardList className="h-5 w-5 text-muted-foreground" /> <ClipboardList className="h-4 w-4 text-muted-foreground" />
View Assessment Assessment
</h1> </p>
{localAssessment && ( {localAssessment && (
<p className="text-sm text-muted-foreground"> <p className="text-xs text-muted-foreground">
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""} {questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
</p> </p>
)} )}
</div> </div>
<Button variant="outline" size="sm" onClick={() => navigate(`/admin/courses/${courseId}/assessment`)}> {localAssessment && (
<Button variant="outline" size="sm" onClick={onModify}>
<NotebookPen className="h-4 w-4 mr-2" /> <NotebookPen className="h-4 w-4 mr-2" />
Modify Assessment Modify Assessment
</Button> </Button>
)}
</div> </div>
{/* Tabs */} {/* ── Sub-tabs ── */}
{localAssessment && ( {localAssessment && (
<div className="flex gap-1 pb-0 -mb-px"> <div className="flex gap-1 border-b">
{TABS.map(({ key, label, icon: Icon }) => ( {TABS.map(({ key, label, icon: Icon }) => (
<button <button
key={key} key={key}
@@ -420,19 +412,15 @@ export default function ViewAssessment() {
))} ))}
</div> </div>
)} )}
</div>
</div>
{/* ── Content ── */} {/* ── Content ── */}
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6"> {initializing || (loading && !localAssessment) ? (
<div className="max-w-3xl mx-auto space-y-5 pb-16">
{loading && !localAssessment ? (
<LoadingSkeleton /> <LoadingSkeleton />
) : !localAssessment ? ( ) : !localAssessment ? (
<div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center"> <div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center">
<ClipboardList className="h-8 w-8 text-muted-foreground/40" /> <ClipboardList className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No assessment has been created for this course yet.</p> <p className="text-sm text-muted-foreground">No assessment has been created for this course yet.</p>
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/assessment`)}> <Button size="sm" variant="outline" onClick={onModify}>
<NotebookPen className="h-4 w-4 mr-2" /> <NotebookPen className="h-4 w-4 mr-2" />
Create Assessment Create Assessment
</Button> </Button>
@@ -478,7 +466,5 @@ export default function ViewAssessment() {
<SessionsTab sessions={sessions} loading={loading} /> <SessionsTab sessions={sessions} loading={loading} />
)} )}
</div> </div>
</div>
</div>
); );
} }
@@ -0,0 +1,31 @@
import { useState } from "react";
import AssessmentOverview from "./AssessmentOverview";
import AssessmentEditor from "./AssessmentEditor";
export default function CourseAssessmentPanel({ courseId }) {
const [mode, setMode] = useState("overview"); // "overview" | "edit"
const [overviewKey, setOverviewKey] = useState(0); // bump to force AssessmentOverview to refetch
const backToOverview = () => {
setOverviewKey((k) => k + 1);
setMode("overview");
};
if (mode === "edit") {
return (
<AssessmentEditor
courseId={courseId}
onSaved={backToOverview}
onCancel={backToOverview}
/>
);
}
return (
<AssessmentOverview
key={overviewKey}
courseId={courseId}
onModify={() => setMode("edit")}
/>
);
}
@@ -72,9 +72,6 @@ export default function CoursesTable() {
}; };
const rowActions = buildRowActions({ const rowActions = buildRowActions({
onViewAssessment: (row) => navigate(`/admin/courses/${row.course_id}/assessment/view`),
onAssessment: (row) => navigate(`/admin/courses/${row.course_id}/assessment`),
onViewUnits: (row) => navigate(`/admin/courses/${row.course_id}/units`),
onView: (row) => navigate(`/admin/courses/${row.course_id}/view`), onView: (row) => navigate(`/admin/courses/${row.course_id}/view`),
onEdit: (row) => navigate(`/admin/courses/${row.course_id}/edit`), onEdit: (row) => navigate(`/admin/courses/${row.course_id}/edit`),
onArchive: (row) => setArchiveTarget(row), onArchive: (row) => setArchiveTarget(row),
@@ -8,8 +8,6 @@ import { useAuth } from "@/contexts/AuthContext";
import DataTable from "@/components/generic/Table/DataTable"; import DataTable from "@/components/generic/Table/DataTable";
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog"; import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
import AttachUnitsDialog from "../library/AttachUnitsDialog";
import { Link2 } from "lucide-react";
import { buildDataColumns, columnPinning } from "../../config/courses/units/columns.config"; import { buildDataColumns, columnPinning } from "../../config/courses/units/columns.config";
import { buildToolbarActions } from "../../config/courses/units/toolbar.config"; import { buildToolbarActions } from "../../config/courses/units/toolbar.config";
@@ -22,7 +20,6 @@ import { formatGeneratedBy } from "@/utils/generatedBy.util";
export default function UnitsTable({ courseId, returnTo }) { export default function UnitsTable({ courseId, returnTo }) {
const [archiveTarget, setArchiveTarget] = useState(null); const [archiveTarget, setArchiveTarget] = useState(null);
const [archiveIds, setArchiveIds] = useState(null); const [archiveIds, setArchiveIds] = useState(null);
const [attachOpen, setAttachOpen] = useState(false);
const tableRefsRef = useRef({ const tableRefsRef = useRef({
getFilters: () => [], getFilters: () => [],
@@ -65,8 +62,7 @@ export default function UnitsTable({ courseId, returnTo }) {
onArchive: (row) => setArchiveTarget(row), onArchive: (row) => setArchiveTarget(row),
}), [courseId]); }), [courseId]);
const toolbarActions = [ const toolbarActions = buildToolbarActions({
...buildToolbarActions({
fetchUnits: (params) => fetchUnits(courseId, params), fetchUnits: (params) => fetchUnits(courseId, params),
pagination, pagination,
exportConfig, exportConfig,
@@ -76,22 +72,7 @@ export default function UnitsTable({ courseId, returnTo }) {
getFilters: () => tableRefsRef.current.getFilters(), getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(), getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance, getTableInstance: () => tableRefsRef.current.tableInstance,
}), });
// Junction revamp — units live standalone in the library; attach without re-creating
{
key: "attach-existing",
type: "button",
label: "Attach Existing",
icon: <Link2 className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => setAttachOpen(true),
},
];
const handleAttachUnits = async (unitIds) => {
await api.post(`/admin/courses/${courseId}/units/attach`, { unit_ids: unitIds });
fetchUnits(courseId, { page: 1, limit: pagination?.limit ?? 10 });
};
const selectionActions = buildSelectionActions({ const selectionActions = buildSelectionActions({
exportConfig, exportConfig,
@@ -174,15 +155,6 @@ export default function UnitsTable({ courseId, returnTo }) {
loading={loading} loading={loading}
onSuccess={handleArchiveSuccess} onSuccess={handleArchiveSuccess}
/> />
{/* ── Attach existing library units ── */}
<AttachUnitsDialog
open={attachOpen}
onOpenChange={setAttachOpen}
attachedUnitIds={units.map((u) => u.unit_id)}
onAttach={handleAttachUnits}
loading={loading}
/>
</> </>
); );
} }
@@ -1,6 +1,6 @@
import { Eye, Archive, ShelvingUnit, NotebookPen, ClipboardList, PlusCircle, ArrowUp, ArrowDown } from "lucide-react"; import { Eye, Archive, ArrowUp, ArrowDown } from "lucide-react";
export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAssessment, onViewAssessment, onMoveUp, onMoveDown, courses = [] }) { export function buildRowActions({ onView, onEdit, onArchive, onMoveUp, onMoveDown, courses = [] }) {
return [ return [
{ {
key: "view", key: "view",
@@ -23,38 +23,6 @@ export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAsse
onClick: (row) => onMoveDown(row), onClick: (row) => onMoveDown(row),
disabled: (row) => courses.findIndex((c) => c.course_id === row.course_id) >= courses.length - 1, disabled: (row) => courses.findIndex((c) => c.course_id === row.course_id) >= courses.length - 1,
}, },
{
key: "view_units",
label: "View Units",
icon: <ShelvingUnit className="h-3.5 w-3.5" />,
className: "text-sky-700 hover:text-sky-600",
onClick: (row) => onViewUnits(row),
separator: true,
},
{
key: "create_assessment",
label: "Create Assessment",
icon: <PlusCircle className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onAssessment(row),
hidden: (row) => !!row.assessment_id,
},
{
key: "view_assessment",
label: "View Assessment",
icon: <ClipboardList className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onViewAssessment(row),
hidden: (row) => !row.assessment_id,
},
{
key: "modify_assessment",
label: "Modify Assessment",
icon: <NotebookPen className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onAssessment(row),
hidden: (row) => !row.assessment_id,
},
{ {
key: "archive", key: "archive",
label: "Archive Course", label: "Archive Course",
+14 -2
View File
@@ -3,13 +3,15 @@ import { useNavigate, useParams } from "react-router-dom";
import { import {
ArrowLeft, Pencil, Clock, BookOpen, Layers, ArrowLeft, Pencil, Clock, BookOpen, Layers,
BadgeCheck, Tag, Star, Lock, ListChecks, BarChart2, BadgeCheck, Tag, Star, Lock, ListChecks, BarChart2,
Trophy, Users, Award, Trophy, Users, Award, ClipboardList,
} from "lucide-react"; } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext"; import { useCourses } from "@/contexts/AdminCoursesContext";
import { useDateFormat } from "@/hooks/useDateFormat"; import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import CourseReadingProgressList from "../../components/courses/CourseReadingProgressList"; import CourseReadingProgressList from "../../components/courses/CourseReadingProgressList";
import UnitsTable from "../../components/courses/UnitsTable";
import CourseAssessmentPanel from "../../components/courses/CourseAssessmentPanel";
import CourseBadge from "@/modules/admin/components/courses/CourseBadge"; import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -296,8 +298,12 @@ function CourseDetailsTab({ course, loading, instructors, achievementKeys, achie
const TABS = [ const TABS = [
{ key: "details", label: "Course Details", icon: BookOpen }, { key: "details", label: "Course Details", icon: BookOpen },
{ key: "progress", label: "Reading Progress", icon: BarChart2 }, { key: "progress", label: "Reading Progress", icon: BarChart2 },
{ key: "units", label: "Units", icon: Layers },
{ key: "assessment", label: "Assessment", icon: ClipboardList },
]; ];
const WIDE_TABS = new Set(["units", "assessment"]);
// ─── Page ───────────────────────────────────────────────────────────────────── // ─── Page ─────────────────────────────────────────────────────────────────────
export default function ViewCourse() { export default function ViewCourse() {
@@ -406,7 +412,7 @@ export default function ViewCourse() {
{/* ── Content ── */} {/* ── Content ── */}
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6"> <div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
<div className="max-w-3xl mx-auto pb-16"> <div className={WIDE_TABS.has(activeTab) ? "pb-16" : "max-w-3xl mx-auto pb-16"}>
{activeTab === "details" && ( {activeTab === "details" && (
<CourseDetailsTab <CourseDetailsTab
course={course} course={course}
@@ -420,6 +426,12 @@ export default function ViewCourse() {
{activeTab === "progress" && ( {activeTab === "progress" && (
<CourseReadingProgressList courseId={courseId} /> <CourseReadingProgressList courseId={courseId} />
)} )}
{activeTab === "units" && (
<UnitsTable courseId={courseId} />
)}
{activeTab === "assessment" && (
<CourseAssessmentPanel courseId={courseId} />
)}
</div> </div>
</div> </div>
</div> </div>
@@ -3,12 +3,13 @@ import { useNavigate, useParams, useLocation } from "react-router-dom";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronLeft, ChevronRight, Check, FileText, ListChecks } from "lucide-react"; import { ChevronLeft, ChevronRight, Check, FileText, ListChecks, Link2, Plus } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext"; import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import api from "@/utils/api.util";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -16,6 +17,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard"; import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import DraftRequirementsEditor from "@/modules/admin/components/courses/DraftRequirementsEditor"; import DraftRequirementsEditor from "@/modules/admin/components/courses/DraftRequirementsEditor";
import AttachUnitsDialog from "@/modules/admin/components/library/AttachUnitsDialog";
const schema = z.object({ const schema = z.object({
title: z.string().min(1, "Title is required."), title: z.string().min(1, "Title is required."),
@@ -42,8 +44,10 @@ export default function AddUnit() {
const backTarget = location.state?.returnTo ?? `/admin/courses/${courseId}/units`; const backTarget = location.state?.returnTo ?? `/admin/courses/${courseId}/units`;
const [mode, setMode] = useState(null); // null | "create" — the Details/Requirements form only shows once "Create" is chosen
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [requirements, setRequirements] = useState([]); const [requirements, setRequirements] = useState([]);
const [attachOpen, setAttachOpen] = useState(false);
const { register, trigger, getValues, formState: { errors, isDirty } } = useForm({ const { register, trigger, getValues, formState: { errors, isDirty } } = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
@@ -89,7 +93,7 @@ export default function AddUnit() {
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => (step === 0 ? navigate(backTarget) : setStep(0))} onClick={() => (mode === "create" && step > 0 ? setStep(0) : navigate(backTarget))}
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
@@ -101,6 +105,23 @@ export default function AddUnit() {
</div> </div>
</div> </div>
{/* Select existing vs. create new */}
<div className="flex items-start justify-between gap-3 pb-3 border-b border-border">
<p className="text-xs text-muted-foreground">
Attach an existing library unit to reuse its content, or create a new one from scratch.
</p>
<div className="flex items-center gap-2 shrink-0">
<Button type="button" variant="outline" size="sm" onClick={() => setAttachOpen(true)}>
<Link2 className="h-3.5 w-3.5 mr-1.5" /> Select
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setMode("create")}>
<Plus className="h-3.5 w-3.5 mr-1.5" /> Create
</Button>
</div>
</div>
{mode === "create" && (
<>
{/* Stepper */} {/* Stepper */}
<div className="flex items-center gap-0"> <div className="flex items-center gap-0">
{STEPS.map((s, i) => { {STEPS.map((s, i) => {
@@ -196,10 +217,24 @@ export default function AddUnit() {
</Button> </Button>
)} )}
</div> </div>
</>
)}
</div> </div>
</div> </div>
{unsavedChangesDialog} {unsavedChangesDialog}
<AttachUnitsDialog
open={attachOpen}
onOpenChange={setAttachOpen}
attachedUnitIds={course?.units?.map((u) => u.unit_id) ?? []}
onAttach={async (unitIds) => {
await api.post(`/admin/courses/${courseId}/units/attach`, { unit_ids: unitIds });
bypassOnce();
navigate(backTarget);
}}
loading={loading}
/>
</section> </section>
); );
} }
@@ -7,7 +7,7 @@ import { nanoid } from "nanoid";
import { import {
ArrowLeft, ChevronLeft, ChevronRight, Check, ArrowLeft, ChevronLeft, ChevronRight, Check,
FileText, BookOpen, LayoutTemplate, ClipboardCheck, ListChecks, FileText, BookOpen, LayoutTemplate, ClipboardCheck, ListChecks,
Plus, Trash2, Plus, Trash2, Link2,
} from "lucide-react"; } from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext"; import { useLibrary } from "@/contexts/AdminLibraryContext";
@@ -33,6 +33,7 @@ import {
} from "@/components/ui/drawer"; } from "@/components/ui/drawer";
import { BlockList, DEFAULT_CONTENT } from "@/components/generic/BlockList"; import { BlockList, DEFAULT_CONTENT } from "@/components/generic/BlockList";
import { AddBlockMenu } from "@/components/generic/AddBlockMenu"; import { AddBlockMenu } from "@/components/generic/AddBlockMenu";
import AttachLessonsDialog from "@/modules/admin/components/library/AttachLessonsDialog";
import { PreviewContent, PreviewChrome } from "../../../components/courses/LessonsPreview"; import { PreviewContent, PreviewChrome } from "../../../components/courses/LessonsPreview";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard"; import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
@@ -167,17 +168,54 @@ function LessonObjectives({ control, register, lessonIndex }) {
); );
} }
function StepLessons({ control, register, errors }) { function StepLessons({ control, register, errors, existingLessons, onRemoveExisting, onOpenAttach }) {
const { fields, append, remove } = useFieldArray({ control, name: "lessons" }); const { fields, append, remove } = useFieldArray({ control, name: "lessons" });
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{fields.length === 0 && ( <div className="flex items-start justify-between gap-3 pb-3 border-b border-border">
<p className="text-xs text-muted-foreground">
Attach existing library lessons to reuse content, or create new ones from scratch.
</p>
<div className="flex items-center gap-2 shrink-0">
<Button type="button" variant="outline" size="sm" onClick={onOpenAttach}>
<Link2 className="h-3.5 w-3.5 mr-1.5" /> Select
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ title: "", description: "", objectives: [], blocks: [] })}
>
<Plus className="h-3.5 w-3.5 mr-1.5" /> Create
</Button>
</div>
</div>
{fields.length === 0 && existingLessons.length === 0 && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No lessons yet. A unit can be created without any, but add one now if you'd like to build its content in this wizard. No lessons yet. A unit can be created without any, but add one now if you'd like to build its content in this wizard.
</p> </p>
)} )}
{existingLessons.map((l) => (
<div key={l.lesson_id} className="border border-border rounded-lg p-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium truncate">{l.title}</span>
<Badge variant="outline" className="text-[10px] shrink-0">existing</Badge>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive h-7 px-2 shrink-0"
onClick={() => onRemoveExisting(l.lesson_id)}
>
Remove
</Button>
</div>
))}
{fields.map((f, i) => ( {fields.map((f, i) => (
<div key={f.id} className="border border-border rounded-lg p-4 space-y-3"> <div key={f.id} className="border border-border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -207,15 +245,6 @@ function StepLessons({ control, register, errors }) {
<LessonObjectives control={control} register={register} lessonIndex={i} /> <LessonObjectives control={control} register={register} lessonIndex={i} />
</div> </div>
))} ))}
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => append({ title: "", description: "", objectives: [], blocks: [] })}
>
<Plus className="h-4 w-4 mr-1" /> Add Lesson
</Button>
</div> </div>
); );
} }
@@ -355,7 +384,7 @@ function SummaryRow({ label, value }) {
); );
} }
function StepReview({ data, tierCategories, requirements }) { function StepReview({ data, tierCategories, requirements, existingLessons }) {
const tierName = tierCategories.find((c) => c.slug === data.subscription)?.name; const tierName = tierCategories.find((c) => c.slug === data.subscription)?.name;
return ( return (
@@ -370,6 +399,20 @@ function StepReview({ data, tierCategories, requirements }) {
<SummaryRow label="Subscription" value={tierName || "No tier gate (open)"} /> <SummaryRow label="Subscription" value={tierName || "No tier gate (open)"} />
</div> </div>
{existingLessons.length > 0 && (
<div className="border border-border rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Attached from library ({existingLessons.length})</span>
</div>
{existingLessons.map((l, i) => (
<div key={l.lesson_id} className="flex items-center justify-between text-sm border-t border-border pt-2 first:border-t-0 first:pt-0">
<span>{i + 1}. {l.title}</span>
</div>
))}
</div>
)}
{(data.lessons ?? []).length === 0 ? ( {(data.lessons ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">No lessons will be created with this unit.</p> <p className="text-sm text-muted-foreground">No lessons will be created with this unit.</p>
) : ( ) : (
@@ -409,7 +452,7 @@ function StepRequirements({ requirements, setRequirements }) {
// ─── Main Page ────────────────────────────────────────────────────────────── // ─── Main Page ──────────────────────────────────────────────────────────────
export default function AddLibraryUnit() { export default function AddLibraryUnit() {
const navigate = useNavigate(); const navigate = useNavigate();
const { createUnitFull, loading } = useLibrary(); const { createUnitFull, attachLessonsToUnit, lessonsFlat, loading } = useLibrary();
const { user } = useAuth(); const { user } = useAuth();
const { syncUnitRequirements } = useCourses(); const { syncUnitRequirements } = useCourses();
@@ -417,6 +460,8 @@ export default function AddLibraryUnit() {
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [requirements, setRequirements] = useState([]); const [requirements, setRequirements] = useState([]);
const [tierCategories, setTierCategories] = useState([]); const [tierCategories, setTierCategories] = useState([]);
const [existingLessons, setExistingLessons] = useState([]);
const [attachLessonsOpen, setAttachLessonsOpen] = useState(false);
useEffect(() => { useEffect(() => {
api.get("/admin/tiers/categories") api.get("/admin/tiers/categories")
@@ -473,6 +518,10 @@ export default function AddLibraryUnit() {
const newUnitId = result?.data?.data?.unit_id; const newUnitId = result?.data?.data?.unit_id;
if (!newUnitId) return; if (!newUnitId) return;
if (existingLessons.length > 0) {
await attachLessonsToUnit(newUnitId, existingLessons.map((l) => l.lesson_id));
}
if (requirements.length > 0) { if (requirements.length > 0) {
const clean = requirements.map(({ _key, ...r }) => r); const clean = requirements.map(({ _key, ...r }) => r);
await syncUnitRequirements(null, newUnitId, clean); await syncUnitRequirements(null, newUnitId, clean);
@@ -551,7 +600,16 @@ export default function AddLibraryUnit() {
/> />
)} )}
{step === 1 && ( {step === 1 && (
<StepLessons control={control} register={register} errors={errors} /> <StepLessons
control={control}
register={register}
errors={errors}
existingLessons={existingLessons}
onRemoveExisting={(lessonId) =>
setExistingLessons((prev) => prev.filter((l) => l.lesson_id !== lessonId))
}
onOpenAttach={() => setAttachLessonsOpen(true)}
/>
)} )}
{step === 2 && ( {step === 2 && (
<StepPageBuilder control={control} setValue={setValue} /> <StepPageBuilder control={control} setValue={setValue} />
@@ -560,7 +618,12 @@ export default function AddLibraryUnit() {
<StepRequirements requirements={requirements} setRequirements={setRequirements} /> <StepRequirements requirements={requirements} setRequirements={setRequirements} />
)} )}
{step === 4 && ( {step === 4 && (
<StepReview data={getValues()} tierCategories={tierCategories} requirements={requirements} /> <StepReview
data={getValues()}
tierCategories={tierCategories}
requirements={requirements}
existingLessons={existingLessons}
/>
)} )}
</div> </div>
@@ -590,6 +653,19 @@ export default function AddLibraryUnit() {
</div> </div>
</div> </div>
<AttachLessonsDialog
open={attachLessonsOpen}
onOpenChange={setAttachLessonsOpen}
attachedLessonIds={existingLessons.map((l) => l.lesson_id)}
onAttach={(lessonIds) => {
const picked = lessonIds
.map((id) => lessonsFlat.find((l) => l.lesson_id === id))
.filter(Boolean)
.map((l) => ({ lesson_id: l.lesson_id, title: l.title }));
setExistingLessons((prev) => [...prev, ...picked]);
}}
/>
{unsavedChangesDialog} {unsavedChangesDialog}
</section> </section>
); );
-4
View File
@@ -55,8 +55,6 @@ import ArchivedLessonsList from '../pages/courses/lessons/ArchivedLessonsList'
// Lesson Page Builder // Lesson Page Builder
import LessonPageBuilder from '../pages/courses/lessons/LessonPageBuilder' import LessonPageBuilder from '../pages/courses/lessons/LessonPageBuilder'
import ViewLessonPage from '../pages/courses/lessons/ViewLessonPage' import ViewLessonPage from '../pages/courses/lessons/ViewLessonPage'
import CourseAssessment from '../pages/courses/CourseAssessment'
import ViewAssessment from '../pages/courses/ViewAssessment'
import ModifyQuiz from '../pages/courses/units/ModifyQuiz' import ModifyQuiz from '../pages/courses/units/ModifyQuiz'
import ViewUnitQuiz from '../pages/courses/units/ViewUnitQuiz' import ViewUnitQuiz from '../pages/courses/units/ViewUnitQuiz'
@@ -194,8 +192,6 @@ export const AdminRoutes = {
{ path: 'add', element: <AddCourse /> }, { path: 'add', element: <AddCourse /> },
{ path: ':courseId/view', element: <ViewCourse /> }, { path: ':courseId/view', element: <ViewCourse /> },
{ path: ':courseId/edit', element: <EditCourse /> }, { path: ':courseId/edit', element: <EditCourse /> },
{ path: ":courseId/assessment", element: <CourseAssessment /> },
{ path: ":courseId/assessment/view", element: <ViewAssessment /> },
// Categories // Categories
{ {