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 { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, Plus, Save, ClipboardList, ChevronUp, ChevronDown, AlertTriangle, Trash2 } from "lucide-react";
import { Plus, Save, ClipboardList, ChevronUp, ChevronDown, AlertTriangle, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { QuestionCard, makeQuestion } from "../../components/courses/QuestionEditor";
import { QuestionCard, makeQuestion } from "./QuestionEditor";
import api from "@/utils/api.util";
// ── Dirty-check snapshot ──────────────────────────────────────────────────────
@@ -114,7 +112,7 @@ const TYPE_LABEL = {
function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs, navContainerRef, errors }) {
return (
<div className="flex flex-col h-full">
<div className="flex flex-col rounded-lg border bg-card overflow-hidden">
{/* Header */}
<div className="px-3 py-3 border-b shrink-0">
@@ -127,7 +125,7 @@ function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs
</div>
{/* 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 ? (
<p className="text-xs text-muted-foreground text-center py-8 px-3">
No questions yet.
@@ -216,15 +214,13 @@ function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs
);
}
// ── Main Page ─────────────────────────────────────────────────────────────────
// ── Main ──────────────────────────────────────────────────────────────────────
export default function CourseAssessment() {
const navigate = useNavigate();
const { courseId } = useParams();
export default function AssessmentEditor({ courseId, onSaved, onCancel }) {
const {
createAssessment, updateAssessment,
bulkSyncAssessmentQuestions,
course, loading,
loading,
} = useCourses();
const [localAssessment, setLocalAssessment] = useState(null);
@@ -258,7 +254,6 @@ export default function CourseAssessment() {
const questionRefs = useRef([]);
const navItemRefs = useRef([]);
const navContainerRef = useRef(null);
const headerRef = useRef(null);
// ── Fetch — silently treat 404 as "no assessment yet" (create mode) ─────────
useEffect(() => {
@@ -313,20 +308,6 @@ export default function CourseAssessment() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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 ─────────────────────────────────
useEffect(() => {
const item = navItemRefs.current[activeIndex];
@@ -363,15 +344,11 @@ export default function CourseAssessment() {
return () => observers.forEach((o) => o.disconnect());
}, [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 el = questionRefs.current[index];
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" });
questionRefs.current[index]?.scrollIntoView({ behavior: "smooth", block: "start" });
};
// ── Question actions ───────────────────────────────────────────────────────
@@ -530,6 +507,7 @@ export default function CourseAssessment() {
initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions });
localStorage.removeItem(DRAFT_KEY);
setDraftInfo(null);
onSaved?.();
};
const handleConfirmSave = async () => {
@@ -542,26 +520,16 @@ export default function CourseAssessment() {
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div className="flex flex-col min-h-screen bg-muted/60">
<PageMeta title={course ? `${course.title} – Assessment - STARR` : undefined} />
<div className="space-y-4">
{/* ── 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-2 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/courses")}>
<ArrowLeft className="h-4 w-4" />
</Button>
{/* ── Toolbar ── */}
<div className="flex items-center gap-2 rounded-lg border bg-card px-4 py-3">
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
<ClipboardList className="h-5 w-5 text-muted-foreground" />
Course Assessment
</h1>
<p className="text-sm text-muted-foreground">
<p className="text-sm font-semibold flex items-center gap-1.5">
<ClipboardList className="h-4 w-4 text-muted-foreground" />
{localAssessment ? "Modify Assessment" : "Create Assessment"}
</p>
<p className="text-xs text-muted-foreground">
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
</p>
</div>
@@ -571,36 +539,29 @@ export default function CourseAssessment() {
<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 />
<Button type="button" variant="outline" size="sm" onClick={handleClearDraft}>
<Trash2 className="h-3.5 w-3.5" />
Clear draft
</Button>
</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}>
{(loading || confirmLoading) ? <Spinner className="h-4 w-4 mr-2" /> : <Save className="h-4 w-4 mr-2" />}
Save Assessment
</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">
<div className="flex flex-col lg:flex-row gap-6 items-start">
{/* LEFT — Navigator (desktop only) */}
<div
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))`,
}}
>
<div className="hidden lg:block w-64 shrink-0">
<QuestionNavigator
questions={questions}
activeIndex={activeIndex}
@@ -613,13 +574,13 @@ export default function CourseAssessment() {
</div>
{/* 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 ? (
<div className="flex items-center justify-center py-20">
<Spinner className="h-6 w-6" />
</div>
) : (
<div className="max-w-5xl space-y-6 pb-16">
<div className="space-y-6 pb-16">
{/* ── Settings ── */}
<div className="rounded-lg border bg-card p-6 space-y-5">
@@ -740,6 +701,7 @@ export default function CourseAssessment() {
<div
key={q._tempId ?? q.question_id ?? i}
ref={(el) => (questionRefs.current[i] = el)}
style={{ scrollMarginTop: "calc(var(--navbar-h, 64px) + 180px)" }}
onClick={() => setActiveIndex(i)}
>
<QuestionCard
@@ -1,7 +1,6 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
ArrowLeft, ClipboardList, NotebookPen,
ClipboardList, NotebookPen,
CheckCircle2, Circle, Users, Activity,
ChevronDown, ChevronUp,
} from "lucide-react";
@@ -10,7 +9,6 @@ import { toast } from "sonner";
import { useCourses } from "@/contexts/AdminCoursesContext";
import api from "@/utils/api.util";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
@@ -327,7 +325,7 @@ function LoadingSkeleton() {
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
// ─── Main ──────────────────────────────────────────────────────────────────────
const TABS = [
{ key: "questions", label: "Questions", icon: ClipboardList },
@@ -335,10 +333,7 @@ const TABS = [
{ key: "sessions", label: "Sessions", icon: Activity },
];
export default function ViewAssessment() {
const navigate = useNavigate();
const { courseId } = useParams();
export default function AssessmentOverview({ courseId, onModify }) {
const {
fetchAssessmentCompletions, fetchAssessmentSessions,
completions, sessions,
@@ -346,10 +341,12 @@ export default function ViewAssessment() {
} = useCourses();
const [localAssessment, setLocalAssessment] = useState(null);
const [initializing, setInitializing] = useState(true);
const [activeTab, setActiveTab] = useState("questions");
useEffect(() => {
(async () => {
setInitializing(true);
try {
const { data } = await api.get(`/admin/courses/${courseId}/assessment`);
setLocalAssessment(data?.data?.data ?? null);
@@ -357,6 +354,8 @@ export default function ViewAssessment() {
if (err?.response?.status !== 404) {
toast(err?.response?.data?.message ?? "Could not load assessment.");
}
} finally {
setInitializing(false);
}
})();
}, [courseId]);
@@ -372,39 +371,32 @@ export default function ViewAssessment() {
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
return (
<div className="flex flex-col min-h-screen bg-muted/60">
<PageMeta title="View Assessment - STARR" />
<div className="space-y-5">
{/* ── Header ── */}
<div
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>
{/* ── Toolbar ── */}
<div className="flex items-center gap-2 rounded-lg border bg-card px-4 py-3">
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
<ClipboardList className="h-5 w-5 text-muted-foreground" />
View Assessment
</h1>
<p className="text-sm font-semibold flex items-center gap-1.5">
<ClipboardList className="h-4 w-4 text-muted-foreground" />
Assessment
</p>
{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" : ""}
</p>
)}
</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" />
Modify Assessment
</Button>
)}
</div>
{/* Tabs */}
{/* ── Sub-tabs ── */}
{localAssessment && (
<div className="flex gap-1 pb-0 -mb-px">
<div className="flex gap-1 border-b">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
@@ -420,19 +412,15 @@ export default function ViewAssessment() {
))}
</div>
)}
</div>
</div>
{/* ── Content ── */}
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
<div className="max-w-3xl mx-auto space-y-5 pb-16">
{loading && !localAssessment ? (
{initializing || (loading && !localAssessment) ? (
<LoadingSkeleton />
) : !localAssessment ? (
<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" />
<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" />
Create Assessment
</Button>
@@ -478,7 +466,5 @@ export default function ViewAssessment() {
<SessionsTab sessions={sessions} loading={loading} />
)}
</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({
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`),
onEdit: (row) => navigate(`/admin/courses/${row.course_id}/edit`),
onArchive: (row) => setArchiveTarget(row),
@@ -8,8 +8,6 @@ import { useAuth } from "@/contexts/AuthContext";
import DataTable from "@/components/generic/Table/DataTable";
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
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 { buildToolbarActions } from "../../config/courses/units/toolbar.config";
@@ -22,7 +20,6 @@ import { formatGeneratedBy } from "@/utils/generatedBy.util";
export default function UnitsTable({ courseId, returnTo }) {
const [archiveTarget, setArchiveTarget] = useState(null);
const [archiveIds, setArchiveIds] = useState(null);
const [attachOpen, setAttachOpen] = useState(false);
const tableRefsRef = useRef({
getFilters: () => [],
@@ -65,8 +62,7 @@ export default function UnitsTable({ courseId, returnTo }) {
onArchive: (row) => setArchiveTarget(row),
}), [courseId]);
const toolbarActions = [
...buildToolbarActions({
const toolbarActions = buildToolbarActions({
fetchUnits: (params) => fetchUnits(courseId, params),
pagination,
exportConfig,
@@ -76,22 +72,7 @@ export default function UnitsTable({ courseId, returnTo }) {
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
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({
exportConfig,
@@ -174,15 +155,6 @@ export default function UnitsTable({ courseId, returnTo }) {
loading={loading}
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 [
{
key: "view",
@@ -23,38 +23,6 @@ export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAsse
onClick: (row) => onMoveDown(row),
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",
label: "Archive Course",
+14 -2
View File
@@ -3,13 +3,15 @@ import { useNavigate, useParams } from "react-router-dom";
import {
ArrowLeft, Pencil, Clock, BookOpen, Layers,
BadgeCheck, Tag, Star, Lock, ListChecks, BarChart2,
Trophy, Users, Award,
Trophy, Users, Award, ClipboardList,
} from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext";
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 api from "@/utils/api.util";
import { Button } from "@/components/ui/button";
@@ -296,8 +298,12 @@ function CourseDetailsTab({ course, loading, instructors, achievementKeys, achie
const TABS = [
{ key: "details", label: "Course Details", icon: BookOpen },
{ 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 ─────────────────────────────────────────────────────────────────────
export default function ViewCourse() {
@@ -406,7 +412,7 @@ export default function ViewCourse() {
{/* ── Content ── */}
<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" && (
<CourseDetailsTab
course={course}
@@ -420,6 +426,12 @@ export default function ViewCourse() {
{activeTab === "progress" && (
<CourseReadingProgressList courseId={courseId} />
)}
{activeTab === "units" && (
<UnitsTable courseId={courseId} />
)}
{activeTab === "assessment" && (
<CourseAssessmentPanel courseId={courseId} />
)}
</div>
</div>
</div>
@@ -3,12 +3,13 @@ import { useNavigate, useParams, useLocation } from "react-router-dom";
import { useForm } from "react-hook-form";
import { z } from "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 { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext";
import { cn } from "@/lib/utils";
import api from "@/utils/api.util";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -16,6 +17,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import DraftRequirementsEditor from "@/modules/admin/components/courses/DraftRequirementsEditor";
import AttachUnitsDialog from "@/modules/admin/components/library/AttachUnitsDialog";
const schema = z.object({
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 [mode, setMode] = useState(null); // null | "create" — the Details/Requirements form only shows once "Create" is chosen
const [step, setStep] = useState(0);
const [requirements, setRequirements] = useState([]);
const [attachOpen, setAttachOpen] = useState(false);
const { register, trigger, getValues, formState: { errors, isDirty } } = useForm({
resolver: zodResolver(schema),
@@ -89,7 +93,7 @@ export default function AddUnit() {
type="button"
variant="ghost"
size="icon"
onClick={() => (step === 0 ? navigate(backTarget) : setStep(0))}
onClick={() => (mode === "create" && step > 0 ? setStep(0) : navigate(backTarget))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
@@ -101,6 +105,23 @@ export default function AddUnit() {
</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 */}
<div className="flex items-center gap-0">
{STEPS.map((s, i) => {
@@ -196,10 +217,24 @@ export default function AddUnit() {
</Button>
)}
</div>
</>
)}
</div>
</div>
{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>
);
}
@@ -7,7 +7,7 @@ import { nanoid } from "nanoid";
import {
ArrowLeft, ChevronLeft, ChevronRight, Check,
FileText, BookOpen, LayoutTemplate, ClipboardCheck, ListChecks,
Plus, Trash2,
Plus, Trash2, Link2,
} from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext";
@@ -33,6 +33,7 @@ import {
} from "@/components/ui/drawer";
import { BlockList, DEFAULT_CONTENT } from "@/components/generic/BlockList";
import { AddBlockMenu } from "@/components/generic/AddBlockMenu";
import AttachLessonsDialog from "@/modules/admin/components/library/AttachLessonsDialog";
import { PreviewContent, PreviewChrome } from "../../../components/courses/LessonsPreview";
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" });
return (
<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">
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>
)}
{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) => (
<div key={f.id} className="border border-border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
@@ -207,15 +245,6 @@ function StepLessons({ control, register, errors }) {
<LessonObjectives control={control} register={register} lessonIndex={i} />
</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>
);
}
@@ -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;
return (
@@ -370,6 +399,20 @@ function StepReview({ data, tierCategories, requirements }) {
<SummaryRow label="Subscription" value={tierName || "No tier gate (open)"} />
</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 ? (
<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 ──────────────────────────────────────────────────────────────
export default function AddLibraryUnit() {
const navigate = useNavigate();
const { createUnitFull, loading } = useLibrary();
const { createUnitFull, attachLessonsToUnit, lessonsFlat, loading } = useLibrary();
const { user } = useAuth();
const { syncUnitRequirements } = useCourses();
@@ -417,6 +460,8 @@ export default function AddLibraryUnit() {
const [step, setStep] = useState(0);
const [requirements, setRequirements] = useState([]);
const [tierCategories, setTierCategories] = useState([]);
const [existingLessons, setExistingLessons] = useState([]);
const [attachLessonsOpen, setAttachLessonsOpen] = useState(false);
useEffect(() => {
api.get("/admin/tiers/categories")
@@ -473,6 +518,10 @@ export default function AddLibraryUnit() {
const newUnitId = result?.data?.data?.unit_id;
if (!newUnitId) return;
if (existingLessons.length > 0) {
await attachLessonsToUnit(newUnitId, existingLessons.map((l) => l.lesson_id));
}
if (requirements.length > 0) {
const clean = requirements.map(({ _key, ...r }) => r);
await syncUnitRequirements(null, newUnitId, clean);
@@ -551,7 +600,16 @@ export default function AddLibraryUnit() {
/>
)}
{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 && (
<StepPageBuilder control={control} setValue={setValue} />
@@ -560,7 +618,12 @@ export default function AddLibraryUnit() {
<StepRequirements requirements={requirements} setRequirements={setRequirements} />
)}
{step === 4 && (
<StepReview data={getValues()} tierCategories={tierCategories} requirements={requirements} />
<StepReview
data={getValues()}
tierCategories={tierCategories}
requirements={requirements}
existingLessons={existingLessons}
/>
)}
</div>
@@ -590,6 +653,19 @@ export default function AddLibraryUnit() {
</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}
</section>
);
-4
View File
@@ -55,8 +55,6 @@ import ArchivedLessonsList from '../pages/courses/lessons/ArchivedLessonsList'
// Lesson Page Builder
import LessonPageBuilder from '../pages/courses/lessons/LessonPageBuilder'
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 ViewUnitQuiz from '../pages/courses/units/ViewUnitQuiz'
@@ -194,8 +192,6 @@ export const AdminRoutes = {
{ path: 'add', element: <AddCourse /> },
{ path: ':courseId/view', element: <ViewCourse /> },
{ path: ':courseId/edit', element: <EditCourse /> },
{ path: ":courseId/assessment", element: <CourseAssessment /> },
{ path: ":courseId/assessment/view", element: <ViewAssessment /> },
// Categories
{