add: ver()

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-08 11:31:54 +08:00
parent 6624c0d27f
commit 182bd93d10
46 changed files with 4407 additions and 98 deletions
@@ -12,7 +12,7 @@ export default function AdminSideTabs() {
const tabItems = [
{ value: "tab-1", label: "Dashboard", paths: ["/admin"] },
{ value: "tab-2", label: "User Management", paths: ["users"] },
{ value: "tab-3", label: "Content Management", paths: ["content", "courses"] },
{ value: "tab-3", label: "Content Management", paths: ["content", "courses", "units", "lessons"] },
{ value: "tab-5", label: "Site Content", paths: ["contents"] },
]
@@ -3,9 +3,12 @@ import { useNavigate } from "react-router-dom";
import { useCourses } from "@/contexts/AdminCoursesContext";
import api from "@/utils/api.util";
import DataTable from "@/components/generic/Table/DataTable";
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
import AttachLessonsDialog from "../library/AttachLessonsDialog";
import { Link2 } from "lucide-react";
import { buildDataColumns, columnPinning } from "../../config/courses/lessons/columns.config";
import { buildToolbarActions } from "../../config/courses/lessons/toolbar.config";
@@ -17,6 +20,7 @@ import { getTimestamp } from "@/utils/timestamp.util";
export default function LessonsTable({ courseId, unitId }) {
const [archiveTarget, setArchiveTarget] = useState(null);
const [archiveIds, setArchiveIds] = useState(null);
const [attachOpen, setAttachOpen] = useState(false);
const tableRefsRef = useRef({
getFilters: () => [],
@@ -55,17 +59,35 @@ export default function LessonsTable({ courseId, unitId }) {
onArchive: (row) => setArchiveTarget(row),
}), [courseId, unitId]);
const toolbarActions = buildToolbarActions({
fetchLessons: (params) => fetchLessons(courseId, unitId, params),
pagination,
exportConfig,
navigate,
courseId,
unitId,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const toolbarActions = [
...buildToolbarActions({
fetchLessons: (params) => fetchLessons(courseId, unitId, params),
pagination,
exportConfig,
navigate,
courseId,
unitId,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
}),
// Junction revamp — lessons 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 handleAttachLessons = async (lessonIds) => {
for (const lesson_id of lessonIds) {
await api.post(`/admin/courses/${courseId}/units/${unitId}/lessons`, { lesson_id });
}
fetchLessons(courseId, unitId, { page: 1, limit: pagination?.limit ?? 10 });
};
const selectionActions = buildSelectionActions({
exportConfig,
@@ -138,6 +160,15 @@ export default function LessonsTable({ courseId, unitId }) {
loading={loading}
onSuccess={handleArchiveSuccess}
/>
{/* ── Attach existing library lessons ── */}
<AttachLessonsDialog
open={attachOpen}
onOpenChange={setAttachOpen}
attachedLessonIds={lessons.map((l) => l.lesson_id)}
onAttach={handleAttachLessons}
loading={loading}
/>
</>
);
}
@@ -8,6 +8,8 @@ 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";
@@ -19,6 +21,7 @@ import { getTimestamp } from "@/utils/timestamp.util";
export default function UnitsTable({ courseId }) {
const [archiveTarget, setArchiveTarget] = useState(null);
const [archiveIds, setArchiveIds] = useState(null);
const [attachOpen, setAttachOpen] = useState(false);
const tableRefsRef = useRef({
getFilters: () => [],
@@ -58,16 +61,32 @@ export default function UnitsTable({ courseId }) {
onArchive: (row) => setArchiveTarget(row),
}), [courseId]);
const toolbarActions = buildToolbarActions({
fetchUnits: (params) => fetchUnits(courseId, params),
pagination,
exportConfig,
navigate,
courseId,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const toolbarActions = [
...buildToolbarActions({
fetchUnits: (params) => fetchUnits(courseId, params),
pagination,
exportConfig,
navigate,
courseId,
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,
@@ -150,6 +169,15 @@ export default function UnitsTable({ courseId }) {
loading={loading}
onSuccess={handleArchiveSuccess}
/>
{/* ── Attach existing library units ── */}
<AttachUnitsDialog
open={attachOpen}
onOpenChange={setAttachOpen}
attachedUnitIds={units.map((u) => u.unit_id)}
onAttach={handleAttachUnits}
loading={loading}
/>
</>
);
}
@@ -0,0 +1,171 @@
import { useMemo, useRef, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useLibrary } from "@/contexts/AdminLibraryContext";
import DataTable from "@/components/generic/Table/DataTable";
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
import { PermanentDeleteDialog } from "@/components/generic/Dialogs/PermanentDeleteDialog";
import { buildDataColumns, columnPinning } from "../../config/library/lessons/columns.config";
import { buildArchivedToolbarActions } from "../../config/library/lessons/toolbar.config";
import { buildArchivedSelectionActions } from "../../config/library/lessons/selection.config";
import { buildArchivedRowActions } from "../../config/library/lessons/rowActions.config";
import { getTimestamp } from "@/utils/timestamp.util";
export default function ArchivedLessonLibraryTable() {
const [restoreTarget, setRestoreTarget] = useState(null);
const [restoreIds, setRestoreIds] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleteIds, setDeleteIds] = useState(null);
const tableRefsRef = useRef({
getFilters: () => [],
getSort: () => [],
resetSelection: () => { },
setFilters: () => { },
});
const navigate = useNavigate();
const {
lessons, attributes, pagination, setPagination, loading,
fetchArchivedLessons, restoreLesson, restoreLessons,
permanentlyDeleteLesson, permanentlyDeleteLessons,
fetchLessonFieldValues, fetchLessonPermanentDeleteImpact,
} = useLibrary();
const handleRefsReady = (refs) => {
tableRefsRef.current = refs;
};
const exportConfig = {
allData: lessons,
attributes,
filename: `${getTimestamp()}_ArchivedLessonLibrary`,
sheetName: "Archived Lessons",
};
const handleFetch = useCallback((params) => fetchArchivedLessons(params), [fetchArchivedLessons]);
const rowActions = useMemo(() => buildArchivedRowActions({
onRestore: (row) => setRestoreTarget(row),
onDelete: (row) => setDeleteTarget(row),
}), []);
const toolbarActions = buildArchivedToolbarActions({
fetchArchivedLessons,
pagination,
exportConfig,
navigate,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const selectionActions = buildArchivedSelectionActions({
exportConfig,
onRestore: (row) => setRestoreTarget(row),
onRestoreMany: (ids) => setRestoreIds(ids),
onDelete: (row) => setDeleteTarget(row),
onDeleteMany: (ids) => setDeleteIds(ids),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const columns = useMemo(
() => buildDataColumns(attributes, rowActions),
[attributes, rowActions]
);
const refresh = () => {
setRestoreTarget(null);
setRestoreIds(null);
setDeleteTarget(null);
setDeleteIds(null);
tableRefsRef.current.resetSelection?.();
fetchArchivedLessons({ page: 1, limit: pagination?.limit ?? 10 });
};
return (
<>
<DataTable
title="Archived Lessons"
data={lessons}
columns={columns}
attributes={attributes}
pagination={pagination}
setPagination={setPagination}
loading={loading}
onFetch={handleFetch}
onFetchFilterData={(field) => fetchLessonFieldValues(field)}
onRefsReady={handleRefsReady}
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
<FilterSheet
open={open}
onOpenChange={onOpenChange}
column={column}
attr={attr}
data={data}
loading={loading}
/>
)}
columnPinning={columnPinning}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
recordLabel="lesson"
emptyMessage="No archived lessons."
/>
{/* ── Restore ── */}
<RestoreDialog
open={!!restoreTarget}
onOpenChange={(v) => !v && setRestoreTarget(null)}
entity={restoreTarget}
entityLabel="Lesson"
getName={(l) => l?.title}
onRestore={(l) => restoreLesson(l?.lesson_id)}
loading={loading}
onSuccess={refresh}
/>
<RestoreDialog
open={!!restoreIds}
onOpenChange={(v) => !v && setRestoreIds(null)}
ids={restoreIds ?? []}
entityLabel="Lesson"
onRestore={(ids) => restoreLessons(ids)}
loading={loading}
onSuccess={refresh}
/>
{/* ── Permanent delete ── */}
<PermanentDeleteDialog
open={!!deleteTarget}
onOpenChange={(v) => !v && setDeleteTarget(null)}
entity={deleteTarget}
entityLabel="Lesson"
getName={(l) => l?.title}
onDelete={(l) => permanentlyDeleteLesson(l?.lesson_id)}
loading={loading}
onSuccess={refresh}
onImpactCheck={async () => {
const impact = await fetchLessonPermanentDeleteImpact(deleteTarget?.lesson_id);
const { unitCount } = impact?.data ?? impact ?? {};
return [
{ label: "unit attachment(s) will be removed", count: unitCount ?? 0 },
];
}}
/>
<PermanentDeleteDialog
open={!!deleteIds}
onOpenChange={(v) => !v && setDeleteIds(null)}
ids={deleteIds ?? []}
entityLabel="Lesson"
onDelete={(ids) => permanentlyDeleteLessons(ids)}
loading={loading}
onSuccess={refresh}
/>
</>
);
}
@@ -0,0 +1,172 @@
import { useMemo, useRef, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useLibrary } from "@/contexts/AdminLibraryContext";
import DataTable from "@/components/generic/Table/DataTable";
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
import { PermanentDeleteDialog } from "@/components/generic/Dialogs/PermanentDeleteDialog";
import { buildDataColumns, columnPinning } from "../../config/library/units/columns.config";
import { buildArchivedToolbarActions } from "../../config/library/units/toolbar.config";
import { buildArchivedSelectionActions } from "../../config/library/units/selection.config";
import { buildArchivedRowActions } from "../../config/library/units/rowActions.config";
import { getTimestamp } from "@/utils/timestamp.util";
export default function ArchivedUnitLibraryTable() {
const [restoreTarget, setRestoreTarget] = useState(null);
const [restoreIds, setRestoreIds] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleteIds, setDeleteIds] = useState(null);
const tableRefsRef = useRef({
getFilters: () => [],
getSort: () => [],
resetSelection: () => { },
setFilters: () => { },
});
const navigate = useNavigate();
const {
units, attributes, pagination, setPagination, loading,
fetchArchivedUnits, restoreUnit, restoreUnits,
permanentlyDeleteUnit, permanentlyDeleteUnits,
fetchUnitFieldValues, fetchUnitPermanentDeleteImpact,
} = useLibrary();
const handleRefsReady = (refs) => {
tableRefsRef.current = refs;
};
const exportConfig = {
allData: units,
attributes,
filename: `${getTimestamp()}_ArchivedUnitLibrary`,
sheetName: "Archived Units",
};
const handleFetch = useCallback((params) => fetchArchivedUnits(params), [fetchArchivedUnits]);
const rowActions = useMemo(() => buildArchivedRowActions({
onRestore: (row) => setRestoreTarget(row),
onDelete: (row) => setDeleteTarget(row),
}), []);
const toolbarActions = buildArchivedToolbarActions({
fetchArchivedUnits,
pagination,
exportConfig,
navigate,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const selectionActions = buildArchivedSelectionActions({
exportConfig,
onRestore: (row) => setRestoreTarget(row),
onRestoreMany: (ids) => setRestoreIds(ids),
onDelete: (row) => setDeleteTarget(row),
onDeleteMany: (ids) => setDeleteIds(ids),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const columns = useMemo(
() => buildDataColumns(attributes, rowActions),
[attributes, rowActions]
);
const refresh = () => {
setRestoreTarget(null);
setRestoreIds(null);
setDeleteTarget(null);
setDeleteIds(null);
tableRefsRef.current.resetSelection?.();
fetchArchivedUnits({ page: 1, limit: pagination?.limit ?? 10 });
};
return (
<>
<DataTable
title="Archived Units"
data={units}
columns={columns}
attributes={attributes}
pagination={pagination}
setPagination={setPagination}
loading={loading}
onFetch={handleFetch}
onFetchFilterData={(field) => fetchUnitFieldValues(field)}
onRefsReady={handleRefsReady}
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
<FilterSheet
open={open}
onOpenChange={onOpenChange}
column={column}
attr={attr}
data={data}
loading={loading}
/>
)}
columnPinning={columnPinning}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
recordLabel="unit"
emptyMessage="No archived units."
/>
{/* ── Restore ── */}
<RestoreDialog
open={!!restoreTarget}
onOpenChange={(v) => !v && setRestoreTarget(null)}
entity={restoreTarget}
entityLabel="Unit"
getName={(u) => u?.title}
onRestore={(u) => restoreUnit(u?.unit_id)}
loading={loading}
onSuccess={refresh}
/>
<RestoreDialog
open={!!restoreIds}
onOpenChange={(v) => !v && setRestoreIds(null)}
ids={restoreIds ?? []}
entityLabel="Unit"
onRestore={(ids) => restoreUnits(ids)}
loading={loading}
onSuccess={refresh}
/>
{/* ── Permanent delete ── */}
<PermanentDeleteDialog
open={!!deleteTarget}
onOpenChange={(v) => !v && setDeleteTarget(null)}
entity={deleteTarget}
entityLabel="Unit"
getName={(u) => u?.title}
onDelete={(u) => permanentlyDeleteUnit(u?.unit_id)}
loading={loading}
onSuccess={refresh}
onImpactCheck={async () => {
const impact = await fetchUnitPermanentDeleteImpact(deleteTarget?.unit_id);
const { lessonCount, courseCount } = impact?.data ?? impact ?? {};
return [
{ label: "lesson attachment(s) will be removed", count: lessonCount ?? 0 },
{ label: "course attachment(s) will be removed", count: courseCount ?? 0 },
];
}}
/>
<PermanentDeleteDialog
open={!!deleteIds}
onOpenChange={(v) => !v && setDeleteIds(null)}
ids={deleteIds ?? []}
entityLabel="Unit"
onDelete={(ids) => permanentlyDeleteUnits(ids)}
loading={loading}
onSuccess={refresh}
/>
</>
);
}
@@ -0,0 +1,129 @@
// AttachLessonsDialog — pick existing library Lessons and attach them to a Unit.
// Junction revamp: attaching creates unit_lessons rows; the Lessons stay standalone.
import { useEffect, useMemo, useState } from "react";
import { Search, Link2 } from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext";
import {
Dialog, DialogContent, DialogDescription, DialogFooter,
DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { formatDuration } from "@/utils/timestamp.util";
export default function AttachLessonsDialog({ open, onOpenChange, attachedLessonIds = [], onAttach, loading }) {
const { lessonsFlat, fetchLessonsFlat, loading: libraryLoading } = useLibrary();
const [query, setQuery] = useState("");
const [selected, setSelected] = useState([]);
useEffect(() => {
if (open) {
setSelected([]);
setQuery("");
fetchLessonsFlat();
}
}, [open, fetchLessonsFlat]);
const attachedSet = useMemo(
() => new Set(attachedLessonIds.map(String)),
[attachedLessonIds]
);
const candidates = useMemo(() => {
const q = query.trim().toLowerCase();
return (lessonsFlat ?? [])
.filter((l) => !attachedSet.has(String(l.lesson_id)))
.filter((l) => !q || l.title?.toLowerCase().includes(q));
}, [lessonsFlat, attachedSet, query]);
const toggle = (lessonId) =>
setSelected((prev) =>
prev.includes(lessonId) ? prev.filter((id) => id !== lessonId) : [...prev, lessonId]
);
const handleAttach = async () => {
if (!selected.length) return;
await onAttach(selected);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Link2 className="h-4 w-4" /> Attach Existing Lessons
</DialogTitle>
<DialogDescription>
Lessons live independently in the library — attaching adds them to this unit without copying.
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search lessons..."
className="pl-8"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<ScrollArea className="h-64 rounded-md border">
{libraryLoading ? (
<div className="flex items-center justify-center h-full py-10">
<Spinner className="h-5 w-5" />
</div>
) : candidates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
{query ? "No lessons match your search." : "Every library lesson is already attached."}
</p>
) : (
<div className="divide-y">
{candidates.map((l) => (
<label
key={l.lesson_id}
className="flex items-center gap-3 px-3 py-2.5 hover:bg-muted/60 cursor-pointer"
>
<Checkbox
checked={selected.includes(l.lesson_id)}
onCheckedChange={() => toggle(l.lesson_id)}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{l.title}</p>
<p className="text-xs text-muted-foreground truncate">
{formatDuration(l.duration_seconds ?? 0)}
</p>
</div>
{Number(l.unit_count) > 0 ? (
<Badge variant="secondary" className="text-xs shrink-0">
in {l.unit_count} unit{Number(l.unit_count) === 1 ? "" : "s"}
</Badge>
) : (
<Badge variant="outline" className="text-xs shrink-0 text-muted-foreground">standalone</Badge>
)}
</label>
))}
</div>
)}
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button onClick={handleAttach} disabled={loading || !selected.length}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Attach {selected.length > 0 ? `(${selected.length})` : ""}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,129 @@
// AttachUnitsDialog — pick existing library Units and attach them to a Course.
// Junction revamp: attaching creates course_units rows; the Units stay standalone.
import { useEffect, useMemo, useState } from "react";
import { Search, Link2 } from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext";
import {
Dialog, DialogContent, DialogDescription, DialogFooter,
DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { formatDuration } from "@/utils/timestamp.util";
export default function AttachUnitsDialog({ open, onOpenChange, attachedUnitIds = [], onAttach, loading }) {
const { unitsFlat, fetchUnitsFlat, loading: libraryLoading } = useLibrary();
const [query, setQuery] = useState("");
const [selected, setSelected] = useState([]);
useEffect(() => {
if (open) {
setSelected([]);
setQuery("");
fetchUnitsFlat();
}
}, [open, fetchUnitsFlat]);
const attachedSet = useMemo(
() => new Set(attachedUnitIds.map(String)),
[attachedUnitIds]
);
const candidates = useMemo(() => {
const q = query.trim().toLowerCase();
return (unitsFlat ?? [])
.filter((u) => !attachedSet.has(String(u.unit_id)))
.filter((u) => !q || u.title?.toLowerCase().includes(q));
}, [unitsFlat, attachedSet, query]);
const toggle = (unitId) =>
setSelected((prev) =>
prev.includes(unitId) ? prev.filter((id) => id !== unitId) : [...prev, unitId]
);
const handleAttach = async () => {
if (!selected.length) return;
await onAttach(selected);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Link2 className="h-4 w-4" /> Attach Existing Units
</DialogTitle>
<DialogDescription>
Units live independently in the library — attaching adds them to this course without copying.
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search units..."
className="pl-8"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<ScrollArea className="h-64 rounded-md border">
{libraryLoading ? (
<div className="flex items-center justify-center h-full py-10">
<Spinner className="h-5 w-5" />
</div>
) : candidates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
{query ? "No units match your search." : "Every library unit is already attached."}
</p>
) : (
<div className="divide-y">
{candidates.map((u) => (
<label
key={u.unit_id}
className="flex items-center gap-3 px-3 py-2.5 hover:bg-muted/60 cursor-pointer"
>
<Checkbox
checked={selected.includes(u.unit_id)}
onCheckedChange={() => toggle(u.unit_id)}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{u.title}</p>
<p className="text-xs text-muted-foreground truncate">
{u.lesson_count ?? 0} lesson{(u.lesson_count ?? 0) === 1 ? "" : "s"} · {formatDuration(u.duration_seconds ?? 0)}
</p>
</div>
{Number(u.course_count) > 0 ? (
<Badge variant="secondary" className="text-xs shrink-0">
in {u.course_count} course{Number(u.course_count) === 1 ? "" : "s"}
</Badge>
) : (
<Badge variant="outline" className="text-xs shrink-0 text-muted-foreground">standalone</Badge>
)}
</label>
))}
</div>
)}
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button onClick={handleAttach} disabled={loading || !selected.length}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Attach {selected.length > 0 ? `(${selected.length})` : ""}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,140 @@
import { useMemo, useRef, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useLibrary } from "@/contexts/AdminLibraryContext";
import DataTable from "@/components/generic/Table/DataTable";
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
import { buildDataColumns, columnPinning } from "../../config/library/lessons/columns.config";
import { buildToolbarActions } from "../../config/library/lessons/toolbar.config";
import { buildSelectionActions } from "../../config/library/lessons/selection.config";
import { buildRowActions } from "../../config/library/lessons/rowActions.config";
import { getTimestamp } from "@/utils/timestamp.util";
export default function LessonLibraryTable() {
const [archiveTarget, setArchiveTarget] = useState(null);
const [archiveIds, setArchiveIds] = useState(null);
const tableRefsRef = useRef({
getFilters: () => [],
getSort: () => [],
resetSelection: () => { },
setFilters: () => { },
});
const navigate = useNavigate();
const {
lessons, attributes, pagination, setPagination, loading,
fetchLessons, archiveLesson, archiveLessons,
fetchLessonFieldValues,
} = useLibrary();
const handleRefsReady = (refs) => {
tableRefsRef.current = refs;
};
const exportConfig = {
allData: lessons,
attributes,
filename: `${getTimestamp()}_LessonLibrary`,
sheetName: "Lessons",
};
const handleFetch = useCallback((params) => fetchLessons(params), [fetchLessons]);
const rowActions = useMemo(() => buildRowActions({
onView: (row) => navigate(`/admin/lessons/${row.lesson_id}/view`),
onEdit: (row) => navigate(`/admin/lessons/${row.lesson_id}/edit`),
onBuildPage: (row) => navigate(`/admin/lessons/${row.lesson_id}/page`),
onViewPage: (row) => navigate(`/admin/lessons/${row.lesson_id}/page/view`),
onArchive: (row) => setArchiveTarget(row),
}), [navigate]);
const toolbarActions = buildToolbarActions({
fetchLessons,
pagination,
exportConfig,
navigate,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const selectionActions = buildSelectionActions({
exportConfig,
onArchive: (row) => setArchiveTarget(row),
onArchiveMany: (ids) => setArchiveIds(ids),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const columns = useMemo(
() => buildDataColumns(attributes, rowActions),
[attributes, rowActions]
);
const handleArchiveSuccess = () => {
setArchiveTarget(null);
setArchiveIds(null);
tableRefsRef.current.resetSelection?.();
fetchLessons({ page: 1, limit: pagination?.limit ?? 10 });
};
return (
<>
<DataTable
title="Lesson Library"
data={lessons}
columns={columns}
attributes={attributes}
pagination={pagination}
setPagination={setPagination}
loading={loading}
onFetch={handleFetch}
onFetchFilterData={(field) => fetchLessonFieldValues(field)}
onRefsReady={handleRefsReady}
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
<FilterSheet
open={open}
onOpenChange={onOpenChange}
column={column}
attr={attr}
data={data}
loading={loading}
/>
)}
columnPinning={columnPinning}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
recordLabel="lesson"
emptyMessage="No lessons in the library yet."
/>
{/* ── Single archive ── */}
<ArchiveDialog
open={!!archiveTarget}
onOpenChange={(v) => !v && setArchiveTarget(null)}
entity={archiveTarget}
entityLabel="Lesson"
getName={(l) => l?.title}
onArchive={(l) => archiveLesson(l?.lesson_id)}
loading={loading}
onSuccess={handleArchiveSuccess}
/>
{/* ── Bulk archive ── */}
<ArchiveDialog
open={!!archiveIds}
onOpenChange={(v) => !v && setArchiveIds(null)}
ids={archiveIds ?? []}
entityLabel="Lesson"
onArchive={(ids) => archiveLessons(ids)}
loading={loading}
onSuccess={handleArchiveSuccess}
/>
</>
);
}
@@ -0,0 +1,150 @@
import { useMemo, useRef, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useLibrary } from "@/contexts/AdminLibraryContext";
import DataTable from "@/components/generic/Table/DataTable";
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
import { buildDataColumns, columnPinning } from "../../config/library/units/columns.config";
import { buildToolbarActions } from "../../config/library/units/toolbar.config";
import { buildSelectionActions } from "../../config/library/units/selection.config";
import { buildRowActions } from "../../config/library/units/rowActions.config";
import { getTimestamp } from "@/utils/timestamp.util";
export default function UnitLibraryTable() {
const [archiveTarget, setArchiveTarget] = useState(null);
const [archiveIds, setArchiveIds] = useState(null);
const tableRefsRef = useRef({
getFilters: () => [],
getSort: () => [],
resetSelection: () => { },
setFilters: () => { },
});
const navigate = useNavigate();
const {
units, attributes, pagination, setPagination, loading,
fetchUnits, archiveUnit, archiveUnits,
fetchUnitFieldValues, fetchUnitArchiveImpact,
} = useLibrary();
const handleRefsReady = (refs) => {
tableRefsRef.current = refs;
};
const exportConfig = {
allData: units,
attributes,
filename: `${getTimestamp()}_UnitLibrary`,
sheetName: "Units",
};
const handleFetch = useCallback((params) => fetchUnits(params), [fetchUnits]);
const rowActions = useMemo(() => buildRowActions({
onView: (row) => navigate(`/admin/units/${row.unit_id}/view`),
onEdit: (row) => navigate(`/admin/units/${row.unit_id}/edit`),
onManageLessons: (row) => navigate(`/admin/units/${row.unit_id}/view`),
onQuiz: (row) => navigate(`/admin/units/${row.unit_id}/quiz/edit`),
onViewQuiz: (row) => navigate(`/admin/units/${row.unit_id}/quiz/view`),
onArchive: (row) => setArchiveTarget(row),
}), [navigate]);
const toolbarActions = buildToolbarActions({
fetchUnits,
pagination,
exportConfig,
navigate,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const selectionActions = buildSelectionActions({
exportConfig,
onArchive: (row) => setArchiveTarget(row),
onArchiveMany: (ids) => setArchiveIds(ids),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const columns = useMemo(
() => buildDataColumns(attributes, rowActions),
[attributes, rowActions]
);
const handleArchiveSuccess = () => {
setArchiveTarget(null);
setArchiveIds(null);
tableRefsRef.current.resetSelection?.();
fetchUnits({ page: 1, limit: pagination?.limit ?? 10 });
};
return (
<>
<DataTable
title="Unit Library"
data={units}
columns={columns}
attributes={attributes}
pagination={pagination}
setPagination={setPagination}
loading={loading}
onFetch={handleFetch}
onFetchFilterData={(field) => fetchUnitFieldValues(field)}
onRefsReady={handleRefsReady}
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
<FilterSheet
open={open}
onOpenChange={onOpenChange}
column={column}
attr={attr}
data={data}
loading={loading}
/>
)}
columnPinning={columnPinning}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
recordLabel="unit"
emptyMessage="No units in the library yet."
/>
{/* ── Single archive ── */}
<ArchiveDialog
open={!!archiveTarget}
onOpenChange={(v) => !v && setArchiveTarget(null)}
entity={archiveTarget}
entityLabel="Unit"
getName={(u) => u?.title}
onArchive={(u) => archiveUnit(u?.unit_id)}
loading={loading}
onSuccess={handleArchiveSuccess}
onImpactCheck={async () => {
const impact = await fetchUnitArchiveImpact(archiveTarget?.unit_id);
const { completionCount, progressCount, courseCount } = impact?.data ?? impact ?? {};
return [
{ label: "student(s) have completed this unit", count: completionCount ?? 0 },
{ label: "student(s) have reading progress in this unit", count: progressCount ?? 0 },
{ label: "course(s) currently include this unit", count: courseCount ?? 0 },
];
}}
/>
{/* ── Bulk archive ── */}
<ArchiveDialog
open={!!archiveIds}
onOpenChange={(v) => !v && setArchiveIds(null)}
ids={archiveIds ?? []}
entityLabel="Unit"
onArchive={(ids) => archiveUnits(ids)}
loading={loading}
onSuccess={handleArchiveSuccess}
/>
</>
);
}
@@ -0,0 +1,48 @@
// config/library/lessons/columns.config.jsx
// Column definitions and pinning for the standalone Lesson Library table.
import { Badge } from "@/components/ui/badge";
import { Clock } from "lucide-react";
import { buildColumns } from "@/utils/table.util";
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
import { formatDuration } from "@/utils/timestamp.util";
export const columnPinning = {
right: ["actions"],
left: [],
};
const cellOverrides = {
duration_seconds: (info) => {
const seconds = parseInt(info.getValue() ?? 0, 10);
return (
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
{formatDuration(seconds)}
</Badge>
</div>
);
},
unit_count: (info) => {
const n = parseInt(info.getValue() ?? 0, 10);
return n > 0 ? (
<Badge variant="secondary" className="text-xs tabular-nums">
in {n} unit{n === 1 ? "" : "s"}
</Badge>
) : (
<Badge variant="outline" className="text-xs text-muted-foreground">standalone</Badge>
);
},
};
export function buildDataColumns(attributes, rowActions) {
const visibleAttributes = attributes.filter((a) => !a.hidden);
return [
buildSelectionColumn(),
...buildColumns(visibleAttributes, { cellOverrides }),
buildRowActionsColumn(rowActions, { dropdownLabel: "Lesson Actions" }),
];
}
@@ -0,0 +1,66 @@
// config/library/lessons/rowActions.config.jsx
// Row actions for the Lesson Library (active + archived variants).
import {
Eye, Archive, Pencil, FileText, LayoutTemplate,
ArchiveRestore, Trash2,
} from "lucide-react";
export function buildRowActions({ onView, onEdit, onBuildPage, onViewPage, onArchive }) {
return [
{
key: "view",
label: "View Info",
icon: <Eye className="h-3.5 w-3.5" />,
onClick: (row) => onView(row),
},
{
key: "edit",
label: "Edit Lesson",
icon: <Pencil className="h-3.5 w-3.5" />,
onClick: (row) => onEdit(row),
},
{
key: "build_page",
label: "Page Builder",
icon: <LayoutTemplate className="h-3.5 w-3.5" />,
className: "text-sky-700 hover:text-sky-600",
onClick: (row) => onBuildPage(row),
separator: true,
},
{
key: "view_page",
label: "View Page",
icon: <FileText className="h-3.5 w-3.5" />,
className: "text-sky-700 hover:text-sky-600",
onClick: (row) => onViewPage(row),
},
{
key: "archive",
label: "Archive",
icon: <Archive className="h-3.5 w-3.5" />,
className: "text-destructive",
onClick: (row) => onArchive(row),
separator: true,
},
];
}
export function buildArchivedRowActions({ onRestore, onDelete }) {
return [
{
key: "restore",
label: "Restore",
icon: <ArchiveRestore className="h-3.5 w-3.5" />,
onClick: (row) => onRestore(row),
},
{
key: "delete",
label: "Delete Permanently",
icon: <Trash2 className="h-3.5 w-3.5" />,
className: "text-destructive",
onClick: (row) => onDelete(row),
separator: true,
},
];
}
@@ -0,0 +1,66 @@
// config/library/lessons/selection.config.jsx
// Bulk selection actions for the Lesson Library (active + archived variants).
import { Download, Archive, ArchiveRestore, Trash2 } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
export function buildSelectionActions({ exportConfig, onArchive, onArchiveMany, getTableInstance }) {
return [
{
key: "export-selected",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
onClick: (rows, table) =>
exportTableToExcel({
...exportConfig,
selectedRows: rows,
tableInstance: table ?? getTableInstance(),
}),
},
{
key: "archive-selected",
label: "Archive",
icon: <Archive className="h-3.5 w-3.5" />,
className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
onClick: (rows) => {
const ids = rows.map((r) => r.lesson_id);
ids.length === 1 ? onArchive(rows[0]) : onArchiveMany(ids);
},
},
];
}
export function buildArchivedSelectionActions({ exportConfig, onRestore, onRestoreMany, onDelete, onDeleteMany, getTableInstance }) {
return [
{
key: "export-selected",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
onClick: (rows, table) =>
exportTableToExcel({
...exportConfig,
selectedRows: rows,
tableInstance: table ?? getTableInstance(),
}),
},
{
key: "restore-selected",
label: "Restore",
icon: <ArchiveRestore className="h-3.5 w-3.5" />,
onClick: (rows) => {
const ids = rows.map((r) => r.lesson_id);
ids.length === 1 ? onRestore(rows[0]) : onRestoreMany(ids);
},
},
{
key: "delete-selected",
label: "Delete Permanently",
icon: <Trash2 className="h-3.5 w-3.5" />,
className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
onClick: (rows) => {
const ids = rows.map((r) => r.lesson_id);
ids.length === 1 ? onDelete(rows[0]) : onDeleteMany(ids);
},
},
];
}
@@ -0,0 +1,105 @@
// config/library/lessons/toolbar.config.jsx
// Toolbar actions for the Lesson Library (active + archived variants).
import { Plus, RefreshCw, Download, Archive, ArrowLeft } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
export function buildToolbarActions({
fetchLessons,
pagination,
exportConfig,
navigate,
getFilters,
getSort,
getTableInstance,
}) {
return [
{
key: "refresh",
type: "button",
label: "Refresh",
icon: <RefreshCw className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => fetchLessons({
page: 1,
limit: pagination?.limit ?? 10,
filters: getFilters(),
sort: getSort(),
}),
},
{
key: "export",
type: "button",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => exportTableToExcel({
...exportConfig,
tableInstance: getTableInstance(),
}),
},
{
key: "create",
type: "button",
label: "New Lesson",
icon: <Plus className="h-3.5 w-3.5" />,
variant: "default",
onClick: () => navigate("/admin/lessons/add"),
},
{
key: "archived-lessons",
type: "button",
icon: <Archive className="h-3.5 w-3.5" />,
label: "Archived Lessons",
variant: "secondary",
className: "border border-border",
onClick: () => navigate("/admin/lessons/archived"),
},
];
}
export function buildArchivedToolbarActions({
fetchArchivedLessons,
pagination,
exportConfig,
navigate,
getFilters,
getSort,
getTableInstance,
}) {
return [
{
key: "back",
type: "button",
label: "Back to Lessons",
icon: <ArrowLeft className="h-3.5 w-3.5" />,
variant: "secondary",
className: "border border-border",
onClick: () => navigate("/admin/lessons"),
},
{
key: "refresh",
type: "button",
label: "Refresh",
icon: <RefreshCw className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => fetchArchivedLessons({
page: 1,
limit: pagination?.limit ?? 10,
filters: getFilters(),
sort: getSort(),
}),
},
{
key: "export",
type: "button",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => exportTableToExcel({
...exportConfig,
tableInstance: getTableInstance(),
}),
},
];
}
@@ -0,0 +1,53 @@
// config/library/units/columns.config.jsx
// Column definitions and pinning for the standalone Unit Library table.
import { Badge } from "@/components/ui/badge";
import { Clock } from "lucide-react";
import { buildColumns } from "@/utils/table.util";
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
import { formatDuration } from "@/utils/timestamp.util";
export const columnPinning = {
right: ["actions"],
left: [],
};
const cellOverrides = {
duration_seconds: (info) => {
const seconds = parseInt(info.getValue() ?? 0, 10);
return (
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
{formatDuration(seconds)}
</Badge>
</div>
);
},
lesson_count: (info) => (
<Badge variant="outline" className="text-xs tabular-nums">
{info.getValue() ?? 0} lesson{(info.getValue() ?? 0) === 1 ? "" : "s"}
</Badge>
),
course_count: (info) => {
const n = parseInt(info.getValue() ?? 0, 10);
return n > 0 ? (
<Badge variant="secondary" className="text-xs tabular-nums">
in {n} course{n === 1 ? "" : "s"}
</Badge>
) : (
<Badge variant="outline" className="text-xs text-muted-foreground">standalone</Badge>
);
},
};
export function buildDataColumns(attributes, rowActions) {
const visibleAttributes = attributes.filter((a) => !a.hidden);
return [
buildSelectionColumn(),
...buildColumns(visibleAttributes, { cellOverrides }),
buildRowActionsColumn(rowActions, { dropdownLabel: "Unit Actions" }),
];
}
@@ -0,0 +1,85 @@
// config/library/units/rowActions.config.jsx
// Row actions for the Unit Library (active + archived variants).
import {
Eye, Archive, BookCheck, NotebookPen, ClipboardList, PlusCircle,
Pencil, ArchiveRestore, Trash2,
} from "lucide-react";
export function buildRowActions({ onView, onEdit, onManageLessons, onArchive, onQuiz, onViewQuiz }) {
return [
{
key: "view",
label: "View Info",
icon: <Eye className="h-3.5 w-3.5" />,
onClick: (row) => onView(row),
},
{
key: "edit",
label: "Edit Unit",
icon: <Pencil className="h-3.5 w-3.5" />,
onClick: (row) => onEdit(row),
},
{
key: "manage_lessons",
label: "Manage Lessons",
icon: <BookCheck className="h-3.5 w-3.5" />,
className: "text-sky-700 hover:text-sky-600",
onClick: (row) => onManageLessons(row),
separator: true,
},
{
key: "create_quiz",
label: "Create Quiz",
icon: <PlusCircle className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onQuiz(row),
hidden: (row) => !!(row.quiz_id || row.quiz),
separator: true,
},
{
key: "view_quiz",
label: "View Quiz",
icon: <ClipboardList className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onViewQuiz(row),
hidden: (row) => !(row.quiz_id || row.quiz),
separator: true,
},
{
key: "modify_quiz",
label: "Modify Quiz",
icon: <NotebookPen className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onQuiz(row),
hidden: (row) => !(row.quiz_id || row.quiz),
},
{
key: "archive",
label: "Archive",
icon: <Archive className="h-3.5 w-3.5" />,
className: "text-destructive",
onClick: (row) => onArchive(row),
separator: true,
},
];
}
export function buildArchivedRowActions({ onRestore, onDelete }) {
return [
{
key: "restore",
label: "Restore",
icon: <ArchiveRestore className="h-3.5 w-3.5" />,
onClick: (row) => onRestore(row),
},
{
key: "delete",
label: "Delete Permanently",
icon: <Trash2 className="h-3.5 w-3.5" />,
className: "text-destructive",
onClick: (row) => onDelete(row),
separator: true,
},
];
}
@@ -0,0 +1,66 @@
// config/library/units/selection.config.jsx
// Bulk selection actions for the Unit Library (active + archived variants).
import { Download, Archive, ArchiveRestore, Trash2 } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
export function buildSelectionActions({ exportConfig, onArchive, onArchiveMany, getTableInstance }) {
return [
{
key: "export-selected",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
onClick: (rows, table) =>
exportTableToExcel({
...exportConfig,
selectedRows: rows,
tableInstance: table ?? getTableInstance(),
}),
},
{
key: "archive-selected",
label: "Archive",
icon: <Archive className="h-3.5 w-3.5" />,
className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
onClick: (rows) => {
const ids = rows.map((r) => r.unit_id);
ids.length === 1 ? onArchive(rows[0]) : onArchiveMany(ids);
},
},
];
}
export function buildArchivedSelectionActions({ exportConfig, onRestore, onRestoreMany, onDelete, onDeleteMany, getTableInstance }) {
return [
{
key: "export-selected",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
onClick: (rows, table) =>
exportTableToExcel({
...exportConfig,
selectedRows: rows,
tableInstance: table ?? getTableInstance(),
}),
},
{
key: "restore-selected",
label: "Restore",
icon: <ArchiveRestore className="h-3.5 w-3.5" />,
onClick: (rows) => {
const ids = rows.map((r) => r.unit_id);
ids.length === 1 ? onRestore(rows[0]) : onRestoreMany(ids);
},
},
{
key: "delete-selected",
label: "Delete Permanently",
icon: <Trash2 className="h-3.5 w-3.5" />,
className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
onClick: (rows) => {
const ids = rows.map((r) => r.unit_id);
ids.length === 1 ? onDelete(rows[0]) : onDeleteMany(ids);
},
},
];
}
@@ -0,0 +1,105 @@
// config/library/units/toolbar.config.jsx
// Toolbar actions for the Unit Library (active + archived variants).
import { Plus, RefreshCw, Download, Archive, ArrowLeft } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
export function buildToolbarActions({
fetchUnits,
pagination,
exportConfig,
navigate,
getFilters,
getSort,
getTableInstance,
}) {
return [
{
key: "refresh",
type: "button",
label: "Refresh",
icon: <RefreshCw className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => fetchUnits({
page: 1,
limit: pagination?.limit ?? 10,
filters: getFilters(),
sort: getSort(),
}),
},
{
key: "export",
type: "button",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => exportTableToExcel({
...exportConfig,
tableInstance: getTableInstance(),
}),
},
{
key: "create",
type: "button",
label: "New Unit",
icon: <Plus className="h-3.5 w-3.5" />,
variant: "default",
onClick: () => navigate("/admin/units/add"),
},
{
key: "archived-units",
type: "button",
icon: <Archive className="h-3.5 w-3.5" />,
label: "Archived Units",
variant: "secondary",
className: "border border-border",
onClick: () => navigate("/admin/units/archived"),
},
];
}
export function buildArchivedToolbarActions({
fetchArchivedUnits,
pagination,
exportConfig,
navigate,
getFilters,
getSort,
getTableInstance,
}) {
return [
{
key: "back",
type: "button",
label: "Back to Units",
icon: <ArrowLeft className="h-3.5 w-3.5" />,
variant: "secondary",
className: "border border-border",
onClick: () => navigate("/admin/units"),
},
{
key: "refresh",
type: "button",
label: "Refresh",
icon: <RefreshCw className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => fetchArchivedUnits({
page: 1,
limit: pagination?.limit ?? 10,
filters: getFilters(),
sort: getSort(),
}),
},
{
key: "export",
type: "button",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => exportTableToExcel({
...exportConfig,
tableInstance: getTableInstance(),
}),
},
];
}
@@ -26,6 +26,15 @@ export default function CourseList() {
<Link to="/admin/tiers/plans" className="text-primary hover:underline font-medium">
Manage Tier Plans
</Link>
{" "}· Units and Lessons now run independently — build them once in the{" "}
<Link to="/admin/units" className="text-primary hover:underline font-medium">
Units Library
</Link>
{" "}and{" "}
<Link to="/admin/lessons" className="text-primary hover:underline font-medium">
Lessons Library
</Link>
, then attach them to any course.
</p>
</div>
<CoursesTable />
@@ -11,6 +11,11 @@ import { PreviewContent, PreviewChrome } from "../../../components/courses/Lesso
export default function ViewLessonPage() {
const navigate = useNavigate();
const { courseId, unitId, lessonId } = useParams();
// Junction revamp — runs course-scoped AND from the standalone Lesson Library.
const builderPath = unitId
? `/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page`
: `/admin/lessons/${lessonId}/page`;
const { fetchLesson, lesson, lessonPage } = useCourses();
const [initializing, setInitializing] = useState(true);
@@ -43,7 +48,7 @@ export default function ViewLessonPage() {
</div>
<Button
type="button"
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page`)}
onClick={() => navigate(builderPath)}
>
Edit Page
</Button>
@@ -73,7 +78,7 @@ export default function ViewLessonPage() {
type="button"
variant="outline"
size="sm"
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page`)}
onClick={() => navigate(builderPath)}
>
Go to Page Builder
</Button>
@@ -223,19 +223,32 @@ export default function ModifyQuiz() {
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" },
];
// Junction revamp — this builder runs course-scoped AND from the standalone
// Unit Library (/admin/units/:unitId/quiz/edit, no :courseId param).
const scopeBase = courseId
? `/admin/courses/${courseId}/units/${unitId}`
: `/admin/units/${unitId}`;
const breadcrumbItems = courseId
? [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Courses", to: "/admin/courses" },
{ label: course?.title ?? "…", to: `/admin/courses/${courseId}` },
{ label: unit?.title ?? "…", to: `/admin/courses/${courseId}/units/${unitId}` },
{ label: "Quiz" },
]
: [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Units Library", to: "/admin/units" },
{ label: unit?.title ?? "…", to: `/admin/units/${unitId}/view` },
{ label: "Quiz" },
];
// ── Fetch — silently treat 404 as "no quiz yet" (create mode) ─────────────
useEffect(() => {
(async () => {
try {
const { data } = await api.get(`/admin/courses/${courseId}/units/${unitId}/quiz`);
const { data } = await api.get(`${scopeBase}/quiz`);
const result = data?.data?.data ?? null;
setLocalQuiz(result);
} catch (err) {
@@ -245,6 +245,11 @@ export default function ViewUnitQuiz() {
const navigate = useNavigate();
const { courseId, unitId } = useParams();
// Junction revamp — runs course-scoped AND from the standalone Unit Library.
const scopeBase = courseId
? `/admin/courses/${courseId}/units/${unitId}`
: `/admin/units/${unitId}`;
const {
fetchQuiz, quiz,
fetchQuizCompletions, completions,
@@ -293,7 +298,7 @@ export default function ViewUnitQuiz() {
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz/edit`)}
onClick={() => navigate(`${scopeBase}/quiz/edit`)}
>
<NotebookPen className="h-4 w-4 mr-2" />
Modify Quiz
@@ -330,7 +335,7 @@ export default function ViewUnitQuiz() {
<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 quiz has been created for this unit yet.</p>
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz/edit`)}>
<Button size="sm" variant="outline" onClick={() => navigate(`${scopeBase}/quiz/edit`)}>
<NotebookPen className="h-4 w-4 mr-2" />
Create Quiz
</Button>
@@ -0,0 +1,100 @@
import { useNavigate, useSearchParams } from "react-router-dom";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft } from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext";
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 { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
const schema = z.object({
title: z.string().min(1, "Title is required."),
description: z.string().optional(),
});
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
export default function AddLibraryLesson() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { createLesson, loading } = useLibrary();
const { user } = useAuth();
// ?unit_id=… → create-and-attach in one call (from the unit lessons manager)
const attachUnitId = searchParams.get("unit_id");
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
defaultValues: { title: "", description: "" },
});
const onSubmit = async (data) => {
const result = await createLesson({
...data,
...(attachUnitId ? { unit_id: attachUnitId } : {}),
createdBy: user?.user_id,
});
if (!result) return;
navigate(attachUnitId ? `/admin/units/${attachUnitId}/view` : "/admin/lessons");
};
return (
<section className="bg-muted h-full">
<PageMeta title="Add Lesson - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Create Lesson</h1>
<p className="text-sm text-muted-foreground">
{attachUnitId
? "This lesson will be created and attached to the unit you came from."
: "Lessons are standalone — attach this one to any unit later, or run it on its own."}
</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="rounded-lg border bg-card p-6 space-y-5">
<div className="space-y-1.5">
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
<Input id="title" placeholder="Lesson title" {...register("title")} />
<FieldError message={errors.title?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
</div>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Lesson
</Button>
</div>
</form>
</div>
</div>
</section>
);
}
@@ -0,0 +1,26 @@
import { House } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import ArchivedLessonLibraryTable from "../../../components/library/ArchivedLessonLibraryTable";
import { PageMeta } from "@/contexts/MetadataContext";
export default function ArchivedLessonLibraryList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Lessons", to: "/admin/lessons" },
{ label: "Archived" },
];
return (
<section className="bg-muted h-full">
<PageMeta title="Archived Lessons - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6">
<AppBreadcrumb items={items} />
</div>
<div className="w-full">
<ArchivedLessonLibraryTable />
</div>
</div>
</section>
);
}
@@ -0,0 +1,102 @@
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft } from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext";
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 { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
const schema = z.object({
title: z.string().min(1, "Title is required."),
description: z.string().optional(),
});
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
export default function EditLibraryLesson() {
const navigate = useNavigate();
const { lessonId } = useParams();
const { fetchLesson, updateLesson, lesson, loading } = useLibrary();
const { user } = useAuth();
const { register, handleSubmit, reset, formState: { errors } } = useForm({
resolver: zodResolver(schema),
defaultValues: { title: "", description: "" },
});
useEffect(() => {
fetchLesson(lessonId);
}, [lessonId]);
useEffect(() => {
if (lesson && String(lesson.lesson_id) === String(lessonId)) {
reset({ title: lesson.title ?? "", description: lesson.description ?? "" });
}
}, [lesson, lessonId, reset]);
const onSubmit = async (data) => {
const result = await updateLesson(lessonId, { ...data, updatedBy: user?.user_id });
if (!result) return;
navigate(`/admin/lessons/${lessonId}/view`);
};
return (
<section className="bg-muted h-full">
<PageMeta title={lesson ? `Edit Lesson – ${lesson.title} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Edit Lesson</h1>
<p className="text-sm text-muted-foreground">
Changes apply everywhere this lesson is attached.
</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="rounded-lg border bg-card p-6 space-y-5">
<div className="space-y-1.5">
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
<Input id="title" placeholder="Lesson title" {...register("title")} />
<FieldError message={errors.title?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
</div>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Save Changes
</Button>
</div>
</form>
</div>
</div>
</section>
);
}
@@ -0,0 +1,37 @@
import { House, Layers } from "lucide-react";
import { Link } from "react-router-dom";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import LessonLibraryTable from "../../../components/library/LessonLibraryTable";
import { PageMeta } from "@/contexts/MetadataContext";
export default function LessonLibraryList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Lessons" },
];
return (
<section className="bg-muted h-full">
<PageMeta title="Lessons Library - STARR" description="Manage standalone lessons — attach them to any unit." />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6">
<AppBreadcrumb items={items} />
</div>
<div className="w-full space-y-4">
<div className="flex items-start gap-3 rounded-lg border bg-card p-4 text-sm">
<Layers className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<p className="text-muted-foreground">
Lessons run <span className="font-medium text-foreground">independently</span> — build content here once,
then attach it to any number of{" "}
<Link to="/admin/units" className="text-primary hover:underline font-medium">
Units
</Link>
. Removing a lesson from a unit only detaches it; the lesson stays in this library.
</p>
</div>
<LessonLibraryTable />
</div>
</div>
</section>
);
}
@@ -0,0 +1,134 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams, Link } from "react-router-dom";
import {
House, Pencil, LayoutTemplate, FileText, Clock, BookCheck,
} from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext";
import { PageMeta } from "@/contexts/MetadataContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import { formatDuration } from "@/utils/timestamp.util";
export default function ViewLibraryLesson() {
const navigate = useNavigate();
const { lessonId } = useParams();
const { fetchLesson, lesson, loading } = useLibrary();
const [initializing, setInitializing] = useState(true);
useEffect(() => {
(async () => {
await fetchLesson(lessonId);
setInitializing(false);
})();
}, [lessonId]);
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Lessons", to: "/admin/lessons" },
{ label: lesson?.title ?? "..." },
];
const units = lesson?.units ?? [];
const blocks = lesson?.page?.blocks ?? [];
const objectives = lesson?.objectives ?? [];
if (initializing) {
return (
<div className="flex items-center justify-center h-64">
<Spinner className="h-6 w-6" />
</div>
);
}
return (
<section className="bg-muted h-full">
<PageMeta title={lesson ? `${lesson.title} - Lessons - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6">
<AppBreadcrumb items={items} />
</div>
<div className="w-full space-y-6 pb-10">
{/* ── Lesson header ── */}
<div className="bg-card rounded-xl border p-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h6 className="text-xs tracking-widest mb-1">STANDALONE LESSON</h6>
<h1 className="text-xl font-semibold">{lesson?.title}</h1>
{lesson?.description && (
<p className="text-sm text-muted-foreground mt-0.5">{lesson.description}</p>
)}
</div>
<div className="flex gap-2 shrink-0">
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/lessons/${lessonId}/edit`)}>
<Pencil className="h-3.5 w-3.5 mr-1.5" /> Edit
</Button>
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/lessons/${lessonId}/page/view`)}>
<FileText className="h-3.5 w-3.5 mr-1.5" /> View Page
</Button>
<Button size="sm" onClick={() => navigate(`/admin/lessons/${lessonId}/page`)}>
<LayoutTemplate className="h-3.5 w-3.5 mr-1.5" /> Page Builder
</Button>
</div>
</div>
{/* Stats row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Blocks</p>
<p className="font-semibold text-sm">{blocks.length}</p>
</div>
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Duration</p>
<p className="font-semibold text-sm flex items-center gap-1">
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
{formatDuration(lesson?.duration_seconds ?? 0)}
</p>
</div>
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Objectives</p>
<p className="font-semibold text-sm">{objectives.length}</p>
</div>
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Used In</p>
<p className="font-semibold text-sm">
{units.length > 0 ? `${units.length} unit${units.length === 1 ? "" : "s"}` : "Standalone"}
</p>
</div>
</div>
{/* Attached units */}
{units.length > 0 && (
<div className="flex flex-wrap items-center gap-2 pt-1">
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Units:</span>
{units.map((u) => (
<Link key={u.unit_id} to={`/admin/units/${u.unit_id}/view`}>
<Badge variant="secondary" className="hover:bg-muted cursor-pointer">
<BookCheck className="h-3 w-3 mr-1" /> {u.title}
</Badge>
</Link>
))}
</div>
)}
</div>
{/* ── Objectives ── */}
{objectives.length > 0 && (
<div className="bg-card rounded-xl border p-6 space-y-3">
<h2 className="font-semibold">Objectives</h2>
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
{[...objectives]
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
.map((o) => <li key={o.objective_id}>{o.text}</li>)}
</ul>
</div>
)}
</div>
</div>
</section>
);
}
@@ -0,0 +1,90 @@
import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft } from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext";
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 { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
const schema = z.object({
title: z.string().min(1, "Title is required."),
description: z.string().optional(),
});
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
export default function AddLibraryUnit() {
const navigate = useNavigate();
const { createUnit, loading } = useLibrary();
const { user } = useAuth();
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
defaultValues: { title: "", description: "" },
});
const onSubmit = async (data) => {
const result = await createUnit({ ...data, createdBy: user?.user_id });
if (!result) return;
navigate("/admin/units");
};
return (
<section className="bg-muted h-full">
<PageMeta title="Add Unit - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Create Unit</h1>
<p className="text-sm text-muted-foreground">
Units are standalone — attach this one to any course later, or run it on its own.
</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="rounded-lg border bg-card p-6 space-y-5">
<div className="space-y-1.5">
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
<Input id="title" placeholder="Unit title" {...register("title")} />
<FieldError message={errors.title?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
</div>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Unit
</Button>
</div>
</form>
</div>
</div>
</section>
);
}
@@ -0,0 +1,26 @@
import { House } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import ArchivedUnitLibraryTable from "../../../components/library/ArchivedUnitLibraryTable";
import { PageMeta } from "@/contexts/MetadataContext";
export default function ArchivedUnitLibraryList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Units", to: "/admin/units" },
{ label: "Archived" },
];
return (
<section className="bg-muted h-full">
<PageMeta title="Archived Units - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6">
<AppBreadcrumb items={items} />
</div>
<div className="w-full">
<ArchivedUnitLibraryTable />
</div>
</div>
</section>
);
}
@@ -0,0 +1,102 @@
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft } from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext";
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 { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
const schema = z.object({
title: z.string().min(1, "Title is required."),
description: z.string().optional(),
});
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
export default function EditLibraryUnit() {
const navigate = useNavigate();
const { unitId } = useParams();
const { fetchUnit, updateUnit, unit, loading } = useLibrary();
const { user } = useAuth();
const { register, handleSubmit, reset, formState: { errors } } = useForm({
resolver: zodResolver(schema),
defaultValues: { title: "", description: "" },
});
useEffect(() => {
fetchUnit(unitId);
}, [unitId]);
useEffect(() => {
if (unit && String(unit.unit_id) === String(unitId)) {
reset({ title: unit.title ?? "", description: unit.description ?? "" });
}
}, [unit, unitId, reset]);
const onSubmit = async (data) => {
const result = await updateUnit(unitId, { ...data, updatedBy: user?.user_id });
if (!result) return;
navigate(`/admin/units/${unitId}/view`);
};
return (
<section className="bg-muted h-full">
<PageMeta title={unit ? `Edit Unit – ${unit.title} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Edit Unit</h1>
<p className="text-sm text-muted-foreground">
Changes apply everywhere this unit is attached.
</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="rounded-lg border bg-card p-6 space-y-5">
<div className="space-y-1.5">
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
<Input id="title" placeholder="Unit title" {...register("title")} />
<FieldError message={errors.title?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
</div>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Save Changes
</Button>
</div>
</form>
</div>
</div>
</section>
);
}
@@ -0,0 +1,37 @@
import { House, Layers } from "lucide-react";
import { Link } from "react-router-dom";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import UnitLibraryTable from "../../../components/library/UnitLibraryTable";
import { PageMeta } from "@/contexts/MetadataContext";
export default function UnitLibraryList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Units" },
];
return (
<section className="bg-muted h-full">
<PageMeta title="Units Library - STARR" description="Manage standalone units — attach them to any course." />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6">
<AppBreadcrumb items={items} />
</div>
<div className="w-full space-y-4">
<div className="flex items-start gap-3 rounded-lg border bg-card p-4 text-sm">
<Layers className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<p className="text-muted-foreground">
Units run <span className="font-medium text-foreground">independently</span> — build them here once,
then attach them to any number of{" "}
<Link to="/admin/courses" className="text-primary hover:underline font-medium">
Courses
</Link>
. Removing a unit from a course only detaches it; the unit stays in this library.
</p>
</div>
<UnitLibraryTable />
</div>
</div>
</section>
);
}
@@ -0,0 +1,237 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams, Link } from "react-router-dom";
import {
ArrowLeft, House, Plus, Link2, Unlink, Pencil,
ChevronUp, ChevronDown, Clock, ClipboardList, PlusCircle,
LayoutTemplate, BookOpen,
} from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext";
import { PageMeta } from "@/contexts/MetadataContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import AttachLessonsDialog from "../../../components/library/AttachLessonsDialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import { formatDuration } from "@/utils/timestamp.util";
export default function ViewLibraryUnit() {
const navigate = useNavigate();
const { unitId } = useParams();
const {
fetchUnit, unit, loading,
attachLessonsToUnit, detachLessonFromUnit, reorderUnitLessons,
} = useLibrary();
const [initializing, setInitializing] = useState(true);
const [attachOpen, setAttachOpen] = useState(false);
useEffect(() => {
(async () => {
await fetchUnit(unitId);
setInitializing(false);
})();
}, [unitId]);
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Units", to: "/admin/units" },
{ label: unit?.title ?? "..." },
];
const lessons = unit?.lessons ?? [];
const courses = unit?.courses ?? [];
const moveLesson = async (index, direction) => {
const target = index + direction;
if (target < 0 || target >= lessons.length) return;
const reordered = [...lessons];
[reordered[index], reordered[target]] = [reordered[target], reordered[index]];
await reorderUnitLessons(unitId, reordered.map((l) => l.lesson_id));
fetchUnit(unitId);
};
const handleDetach = async (lessonId) => {
await detachLessonFromUnit(unitId, lessonId);
fetchUnit(unitId);
};
const handleAttach = async (lessonIds) => {
await attachLessonsToUnit(unitId, lessonIds);
fetchUnit(unitId);
};
if (initializing) {
return (
<div className="flex items-center justify-center h-64">
<Spinner className="h-6 w-6" />
</div>
);
}
return (
<section className="bg-muted h-full">
<PageMeta title={unit ? `${unit.title} - Units - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6">
<AppBreadcrumb items={items} />
</div>
<div className="w-full space-y-6 pb-10">
{/* ── Unit header ── */}
<div className="bg-card rounded-xl border p-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h6 className="text-xs tracking-widest mb-1">STANDALONE UNIT</h6>
<h1 className="text-xl font-semibold">{unit?.title}</h1>
{unit?.description && (
<p className="text-sm text-muted-foreground mt-0.5">{unit.description}</p>
)}
</div>
<div className="flex gap-2 shrink-0">
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/units/${unitId}/edit`)}>
<Pencil className="h-3.5 w-3.5 mr-1.5" /> Edit
</Button>
{unit?.quiz ? (
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/units/${unitId}/quiz/view`)}>
<ClipboardList className="h-3.5 w-3.5 mr-1.5" /> View Quiz
</Button>
) : (
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/units/${unitId}/quiz/edit`)}>
<PlusCircle className="h-3.5 w-3.5 mr-1.5" /> Create Quiz
</Button>
)}
</div>
</div>
{/* Stats row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Lessons</p>
<p className="font-semibold text-sm">{lessons.length}</p>
</div>
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Duration</p>
<p className="font-semibold text-sm">{formatDuration(unit?.duration_seconds ?? 0)}</p>
</div>
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Quiz</p>
<p className="font-semibold text-sm">{unit?.quiz ? unit.quiz.title || "Unit Quiz" : "None"}</p>
</div>
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Used In</p>
<p className="font-semibold text-sm">
{courses.length > 0 ? `${courses.length} course${courses.length === 1 ? "" : "s"}` : "Standalone"}
</p>
</div>
</div>
{/* Attached courses */}
{courses.length > 0 && (
<div className="flex flex-wrap items-center gap-2 pt-1">
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Courses:</span>
{courses.map((c) => (
<Link key={c.course_id} to={`/admin/courses/${c.course_id}/view`}>
<Badge variant="secondary" className="hover:bg-muted cursor-pointer">
<BookOpen className="h-3 w-3 mr-1" /> {c.title}
</Badge>
</Link>
))}
</div>
)}
</div>
{/* ── Lessons manager ── */}
<div className="bg-card rounded-xl border">
<div className="flex items-center justify-between p-4 border-b">
<div>
<h2 className="font-semibold">Lessons</h2>
<p className="text-xs text-muted-foreground">
Lessons are standalone too — attach existing ones or create new. Order here is this unit's order.
</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => setAttachOpen(true)}>
<Link2 className="h-3.5 w-3.5 mr-1.5" /> Attach Existing
</Button>
<Button size="sm" onClick={() => navigate(`/admin/lessons/add?unit_id=${unitId}`)}>
<Plus className="h-3.5 w-3.5 mr-1.5" /> New Lesson
</Button>
</div>
</div>
{lessons.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
No lessons attached yet — attach one from the library or create a new one.
</p>
) : (
<div className="divide-y">
{lessons.map((l, i) => (
<div key={l.lesson_id} className="flex items-center gap-3 px-4 py-3">
<span className="text-xs font-mono text-muted-foreground w-6 text-right shrink-0">
{i + 1}.
</span>
<div className="flex flex-col gap-0.5 shrink-0">
<button
type="button"
className="text-muted-foreground hover:text-foreground disabled:opacity-30"
disabled={i === 0 || loading}
onClick={() => moveLesson(i, -1)}
aria-label="Move up"
>
<ChevronUp className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="text-muted-foreground hover:text-foreground disabled:opacity-30"
disabled={i === lessons.length - 1 || loading}
onClick={() => moveLesson(i, 1)}
aria-label="Move down"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{l.title}</p>
{l.description && (
<p className="text-xs text-muted-foreground truncate">{l.description}</p>
)}
</div>
<Badge variant="secondary" className="text-xs shrink-0 tabular-nums">
<Clock className="h-3 w-3 mr-1" /> {formatDuration(l.duration_seconds ?? 0)}
</Badge>
<Button
size="sm" variant="ghost"
onClick={() => navigate(`/admin/lessons/${l.lesson_id}/page`)}
title="Page Builder"
>
<LayoutTemplate className="h-3.5 w-3.5" />
</Button>
<Button
size="sm" variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => handleDetach(l.lesson_id)}
disabled={loading}
title="Detach from unit (lesson stays in library)"
>
<Unlink className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
</div>
<AttachLessonsDialog
open={attachOpen}
onOpenChange={setAttachOpen}
attachedLessonIds={lessons.map((l) => l.lesson_id)}
onAttach={handleAttach}
loading={loading}
/>
</section>
);
}
+44
View File
@@ -58,6 +58,18 @@ import ViewAssessment from '../pages/courses/ViewAssessment'
import ModifyQuiz from '../pages/courses/units/ModifyQuiz'
import ViewUnitQuiz from '../pages/courses/units/ViewUnitQuiz'
// Standalone Libraries (junction revamp — Units/Lessons run independently)
import UnitLibraryList from '../pages/library/units/UnitLibraryList'
import AddLibraryUnit from '../pages/library/units/AddLibraryUnit'
import EditLibraryUnit from '../pages/library/units/EditLibraryUnit'
import ViewLibraryUnit from '../pages/library/units/ViewLibraryUnit'
import ArchivedUnitLibraryList from '../pages/library/units/ArchivedUnitLibraryList'
import LessonLibraryList from '../pages/library/lessons/LessonLibraryList'
import AddLibraryLesson from '../pages/library/lessons/AddLibraryLesson'
import EditLibraryLesson from '../pages/library/lessons/EditLibraryLesson'
import ViewLibraryLesson from '../pages/library/lessons/ViewLibraryLesson'
import ArchivedLessonLibraryList from '../pages/library/lessons/ArchivedLessonLibraryList'
// Task List
import TaskList from '../pages/task_list/TaskList'
@@ -231,6 +243,38 @@ export const AdminRoutes = {
},
// Units Library — standalone Units (junction revamp)
{
path: 'units',
element: <Outlet />,
children: [
{ index: true, element: <UnitLibraryList /> },
{ path: 'add', element: <AddLibraryUnit /> },
{ path: 'archived', element: <ArchivedUnitLibraryList /> },
{ path: ':unitId/view', element: <ViewLibraryUnit /> },
{ path: ':unitId/edit', element: <EditLibraryUnit /> },
// Quiz builder runs in library mode (no :courseId param)
{ path: ':unitId/quiz/edit', element: <ModifyQuiz /> },
{ path: ':unitId/quiz/view', element: <ViewUnitQuiz /> },
]
},
// Lessons Library — standalone Lessons (junction revamp)
{
path: 'lessons',
element: <Outlet />,
children: [
{ index: true, element: <LessonLibraryList /> },
{ path: 'add', element: <AddLibraryLesson /> },
{ path: 'archived', element: <ArchivedLessonLibraryList /> },
{ path: ':lessonId/view', element: <ViewLibraryLesson /> },
{ path: ':lessonId/edit', element: <EditLibraryLesson /> },
// Page builder runs in library mode (no :courseId/:unitId params)
{ path: ':lessonId/page', element: <LessonPageBuilder /> },
{ path: ':lessonId/page/view', element: <ViewLessonPage /> },
]
},
// Task
{
path: 'taskList',
@@ -0,0 +1,90 @@
// UnitCard — grid card for a standalone Unit. Shared by UnitsList.jsx and
// Dashboard.jsx's "Featured Units" section.
import { Timer, LockIcon, Layers, BookOpen, ClipboardList } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
function formatDuration(seconds = 0) {
if (!seconds) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h && m) return `${h}h ${m}m`;
if (h) return `${h}h`;
return `${m}m`;
}
export const UnitCard = ({ unit, onViewDetails }) => {
const locked = unit.is_locked;
const duration = formatDuration(unit.duration_seconds);
const lessonCount = Number(unit.lesson_count ?? 0);
const courseCount = Number(unit.course_count ?? 0);
return (
<div
className={cn(
"bg-card rounded-2xl border p-4 flex flex-col gap-2.5 transition-all cursor-pointer group",
"hover:shadow-sm",
locked
? "opacity-80 hover:opacity-100 hover:border-muted-foreground/40"
: "hover:bg-muted/60 dark:hover:border-blue-500"
)}
onClick={() => onViewDetails(unit)}
>
<div className="flex flex-wrap gap-1.5">
{locked ? (
<Badge variant="secondary">
<LockIcon className="size-3" /> Locked
</Badge>
) : courseCount > 0 ? (
<Badge variant="outline"><BookOpen className="size-3" /> In {courseCount} course{courseCount === 1 ? "" : "s"}</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">Standalone</Badge>
)}
{unit.quiz_id && (
<Badge variant="outline"><ClipboardList className="size-3" /> Quiz</Badge>
)}
</div>
<div className="flex flex-col gap-1">
<h1 className="text-lg font-medium leading-snug line-clamp-3 transition-colors group-hover:text-blue-700 dark:group-hover:text-blue-400">
{unit.title}
</h1>
{unit.description && (
<p className="text-sm leading-relaxed line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-blue-400">
{unit.description}
</p>
)}
</div>
<div className="flex items-center justify-between pt-2 mt-auto border-t">
<div className={`flex items-center gap-3 text-sm [&_svg]:size-4 ${locked ? "text-muted-foreground" : "text-card-foreground group-hover:text-blue-700 dark:group-hover:text-blue-400"}`}>
<div className="flex items-center gap-1">
<Layers /> {lessonCount} {lessonCount === 1 ? "Lesson" : "Lessons"}
</div>
<div className="flex items-center gap-1">
<Timer /> {duration ?? "—"}
</div>
</div>
{locked && (
<span className="text-xs text-muted-foreground">Upgrade to unlock</span>
)}
</div>
</div>
);
};
export const UnitCardSkeleton = () => (
<div className="bg-card rounded-2xl border p-4 flex flex-col gap-2.5">
<div className="flex gap-1.5">
<Skeleton className="h-5 w-20 rounded-full" />
</div>
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<div className="pt-2 mt-auto border-t">
<Skeleton className="h-4 w-24" />
</div>
</div>
);
@@ -0,0 +1,70 @@
// UnitUpsellModal — shown when a learner clicks a locked standalone Unit.
// Units aren't independently purchasable (no Product row keyed to unit_id, only
// to course_id), so unlike CourseCard's single "Buy $X" button, this lists every
// course the unit is attached to so the learner can pick one to view/buy, plus a
// generic "View Plans" fallback. Shared by UnitsList, UnitDetails, and Dashboard.
import { useNavigate } from "react-router-dom";
import { LockIcon, BookOpen } from "lucide-react";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { resolveTierBadge } from "@/utils/tierBadge.util";
export default function UnitUpsellModal({ open, onOpenChange, unit, tierMap = {} }) {
const navigate = useNavigate();
const courses = unit?.courses ?? [];
return (
<ResponsiveModal
open={open}
onOpenChange={onOpenChange}
title={unit?.title ?? "Unit Details"}
description="This unit is part of one or more courses that require a plan upgrade."
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
<Button onClick={() => { onOpenChange(false); navigate("/plans"); }}>
<LockIcon /> View Plans
</Button>
</>
}
>
<div className="space-y-3 py-2">
{courses.length === 0 ? (
<p className="text-sm text-muted-foreground">
Upgrade your plan to access this content.
</p>
) : (
courses.map((course) => {
const { label, cls } = resolveTierBadge(course.subscription, tierMap);
return (
<div
key={course.course_id}
className="flex items-center justify-between gap-3 p-4 rounded-xl border bg-muted/40"
>
<div className="flex items-center gap-2.5 min-w-0">
<BookOpen className="size-4 text-muted-foreground shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{course.title}</p>
<Badge className={`${cls} mt-1`}>
<LockIcon className="size-3" /> {label}
</Badge>
</div>
</div>
<Button
size="sm"
variant="outline"
className="shrink-0"
onClick={() => { onOpenChange(false); navigate(`/course/${course.course_id}`); }}
>
View Course
</Button>
</div>
);
})
)}
</div>
</ResponsiveModal>
);
}
+6 -10
View File
@@ -171,7 +171,6 @@ const CoursesList = () => {
const [tierCategories, setTierCategories] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
const [search, setSearch] = useState("");
const [levelFilter, setLevelFilter] = useState("All");
const [subFilter, setSubFilter] = useState("All");
const [categoryFilter, setCategoryFilter] = useState("All");
const [modalOpen, setModalOpen] = useState(false);
@@ -205,13 +204,12 @@ const CoursesList = () => {
.filter((c) => {
const matchSearch = c.title.toLowerCase().includes(search.toLowerCase()) ||
(c.description ?? "").toLowerCase().includes(search.toLowerCase());
const matchLevel = levelFilter === "All" || (c.level ?? "").toLowerCase() === levelFilter.toLowerCase();
const matchSub = subFilter === "All" || c.subscription === subFilter;
const matchCategory = categoryFilter === "All" || (c.categories ?? []).some((cat) => String(cat.id) === categoryFilter);
return matchSearch && matchLevel && matchSub && matchCategory;
return matchSearch && matchSub && matchCategory;
})
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)),
[courses, search, levelFilter, subFilter, categoryFilter]
[courses, search, subFilter, categoryFilter]
);
const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
@@ -251,15 +249,13 @@ const CoursesList = () => {
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
/>
<div className="flex gap-4 items-start w-full">
<Select value={levelFilter} onValueChange={(v) => { setLevelFilter(v); setCurrentPage(1); }}>
<Select value="courses" onValueChange={(v) => { if (v === "units") navigate("/units"); }}>
<SelectTrigger className="w-full lg:w-48 bg-card">
<SelectValue placeholder="Level" />
<SelectValue placeholder="Browse" />
</SelectTrigger>
<SelectContent>
<SelectItem value="All">All Levels</SelectItem>
<SelectItem value="Beginner">Beginner</SelectItem>
<SelectItem value="Intermediate">Intermediate</SelectItem>
<SelectItem value="Advanced">Advanced</SelectItem>
<SelectItem value="courses">Courses</SelectItem>
<SelectItem value="units">Units</SelectItem>
</SelectContent>
</Select>
+54
View File
@@ -15,7 +15,10 @@ import { useNavigate, useLocation } from "react-router-dom";
import { toast } from "sonner";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { useClientCourses } from "@/contexts/ClientCoursesContext";
import { useLibrary } from "@/contexts/ClientLibraryContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import UnitUpsellModal from "../components/UnitUpsellModal";
import { UnitCard, UnitCardSkeleton } from "../components/UnitCard";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import { cn } from "@/lib/utils";
import { useGroup } from "@/contexts/ClientGroupContext";
@@ -205,6 +208,7 @@ const Client = () => {
const navigate = useNavigate();
const { state: navState } = useLocation();
const { courses, coursesLoading, getCourses } = useClientCourses();
const { units, unitsLoading, getUnits } = useLibrary();
const { myTier, getMyTier, tierMap } = useClientTiers();
const { groups, fetchGroups, loading: groupLoading } = useGroup();
const {
@@ -216,6 +220,9 @@ const Client = () => {
const [modalOpen, setModalOpen] = useState(false);
const [selectedCourse, setSelectedCourse] = useState(null);
const [unitModalOpen, setUnitModalOpen] = useState(false);
const [selectedUnit, setSelectedUnit] = useState(null);
const [popupOpen, setPopupOpen] = useState(false);
const userTier = myTier?.tier ?? "free";
@@ -235,6 +242,7 @@ const Client = () => {
useEffect(() => {
getCourses();
getUnits();
if (!myTier) getMyTier();
}, []);
@@ -253,6 +261,7 @@ const Client = () => {
// Show only first 3
const featuredCourses = courses.slice(0, 3);
const featuredUnits = units.slice(0, 3);
const breadcrumbItems = [
{ label: "My Groups", icon: <Users className="size-4" /> },
@@ -269,6 +278,15 @@ const Client = () => {
}
};
const handleViewUnitDetails = (unit) => {
if (unit.is_locked) {
setSelectedUnit(unit);
setUnitModalOpen(true);
} else {
navigate(`/units/${unit.uuid}`);
}
};
return (
<div>
@@ -332,6 +350,34 @@ const Client = () => {
)}
</div>
{/* ── Featured Units (first 3) ── */}
<div className="flex flex-col gap-4">
<div className="w-full flex items-center justify-between">
<h1 className="text-2xl font-medium">Units</h1>
<Button onClick={() => navigate(`/units`)}>View All</Button>
</div>
{unitsLoading ? (
<div className="grid lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<UnitCardSkeleton key={i} />
))}
</div>
) : featuredUnits.length === 0 ? (
<p className="text-sm text-muted-foreground">No units available yet.</p>
) : (
<div className="grid lg:grid-cols-3 gap-4">
{featuredUnits.map((unit) => (
<UnitCard
key={unit.unit_id}
unit={unit}
onViewDetails={handleViewUnitDetails}
/>
))}
</div>
)}
</div>
</div>
</div>
@@ -383,6 +429,14 @@ const Client = () => {
})()}
</div>
</ResponsiveModal>
{/* ── Upsell Modal — only for locked units ── */}
<UnitUpsellModal
open={unitModalOpen}
onOpenChange={setUnitModalOpen}
unit={selectedUnit}
tierMap={tierMap}
/>
</div>
);
};
+251
View File
@@ -0,0 +1,251 @@
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate } from "react-router-dom";
import {
House, Timer, Layers, LockIcon, SendHorizonal, CheckCheck, CheckCircle2, Circle,
FileQuestion, Hourglass, Zap, ClipboardList,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { motion } from "framer-motion";
import { useEffect } from "react";
import { useLibrary } from "@/contexts/ClientLibraryContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { PageMeta } from "@/contexts/MetadataContext";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatDuration(seconds = 0) {
if (!seconds) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}hr ${m}min`;
return `${m}min`;
}
// ─── Content card (single unit — lessons + quiz, no multi-unit spine) ─────────
const UnitContentCard = ({ unitDetail, onLessonClick, onQuizClick }) => {
const lessons = unitDetail.lessons ?? [];
const quiz = unitDetail.quiz ?? null;
return (
<motion.div
className="rounded-xl border bg-card"
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
>
<div className="px-4 py-4 border-b flex flex-col gap-2">
<div className="[&_svg]:size-4 text-md text-muted-foreground flex items-center gap-4">
{lessons.length > 0 && (
<div className="flex items-center gap-1.5">
<Layers /> {lessons.length} {lessons.length === 1 ? "Lesson" : "Lessons"}
</div>
)}
{unitDetail.duration_seconds > 0 && (
<div className="flex items-center gap-1.5">
<Timer /> {formatDuration(unitDetail.duration_seconds)}
</div>
)}
{quiz && (
<div className="flex items-center gap-1.5">
<FileQuestion /> Quiz
</div>
)}
</div>
</div>
<div className="flex flex-col gap-1 p-1.5">
{lessons.map((lesson) => (
<div
key={lesson.lesson_id}
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-slate-200 dark:hover:bg-blue-500 transition-colors cursor-pointer"
onClick={() => onLessonClick(lesson)}
>
<div className="flex items-center gap-3 select-none min-w-0">
{lesson.status === "completed"
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0" />
: <Circle className="size-4 text-muted-foreground/40 shrink-0" />
}
<span className="text-md text-card-foreground truncate">{lesson.title}</span>
</div>
{lesson.duration_seconds > 0 && (
<span className="text-sm shrink-0 ml-2">{formatDuration(lesson.duration_seconds)}</span>
)}
</div>
))}
{quiz && (
<div
className="flex items-center justify-between py-2 px-3 mt-1 rounded-lg border border-dashed border-blue-300 dark:border-blue-700 bg-blue-50/60 dark:bg-blue-950/30 hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors cursor-pointer"
onClick={() => onQuizClick()}
>
<div className="flex items-center gap-3 select-none min-w-0">
{quiz.has_passed
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0" />
: <ClipboardList className="size-4 text-blue-500 shrink-0" />
}
<span className="text-sm font-medium text-blue-700 dark:text-blue-300 truncate">{quiz.title || "Quiz"}</span>
</div>
<Badge className={cn(
"shrink-0 ml-2 text-[10px]",
quiz.has_passed
? "bg-green-100 text-green-700 border border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700"
: "bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/40 dark:text-blue-400 dark:border-blue-700"
)}>
{quiz.has_passed ? "Passed" : "Quiz"}
</Badge>
</div>
)}
</div>
</motion.div>
);
};
// ─── Unit Details ───────────────────────────────────────────────────────────
const UnitDetails = () => {
const { uuid } = useParams();
const navigate = useNavigate();
const { getUnitDetail, unitDetail, unitDetailLoading, unitBlocked, unitBlockedInfo, resetUnitDetail } = useLibrary();
const { tierMap, getTierCategories } = useClientTiers();
const hasCompleted = !!unitDetail?.is_completed;
const contentNotReady = !unitDetail?.duration_seconds;
useEffect(() => {
getTierCategories();
getUnitDetail(uuid);
return () => resetUnitDetail();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uuid]);
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
{ label: "Units", to: `/units` },
{ label: unitDetail?.title ?? "Unit" },
];
// ── Deep-link to a locked unit — inline blocked panel, not a redirect ────
if (unitBlocked) {
const course = unitBlockedInfo?.course;
const tier = course?.subscription ? tierMap[course.subscription] : null;
return (
<div className="flex flex-col items-center gap-6 py-32 text-center px-4">
<div className="h-16 w-16 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<LockIcon className="size-7 text-amber-500" />
</div>
<div className="space-y-2 max-w-sm">
<h2 className="text-xl font-semibold">Premium / Exclusive Content</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
{course
? `This unit is part of "${course.title}"${tier?.name ? ` (${tier.name} plan)` : ""}. Upgrade your plan or view the course to unlock it.`
: "Upgrade your plan to access this unit."}
</p>
</div>
<div className="flex flex-col items-center gap-2">
<Button onClick={() => navigate('/plans')} className="gap-1.5">
<Zap className="size-4" /> View Available Plans
</Button>
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this tier.</p>
</div>
</div>
);
}
if (unitDetailLoading) {
return (
<div className="my-17 p-8 lg:container lg:mx-auto space-y-4">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-10 w-2/3" />
<Skeleton className="h-5 w-full max-w-2xl" />
</div>
);
}
const handleLessonClick = (lesson) => {
navigate(`/units/${uuid}/read`, { state: { lessonId: lesson.lesson_id } });
};
const handleQuizClick = () => {
navigate(`/units/${uuid}/read`, { state: { quizId: true } });
};
return (
<div>
<PageMeta title={unitDetail ? `${unitDetail.title} - STARR` : undefined} description={unitDetail?.description} />
<div className="my-17">
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-6">
{/* Hero */}
<div className="bg-primary dark:bg-accent/50">
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-6 xs:py-6 lg:px-4 lg:py-16">
<AppBreadcrumb
color={{ link: { color: "text-white" }, page: { color: "text-white" } }}
items={items}
/>
<div className="flex lg:flex-row items-start justify-between w-full text-white">
<div className="flex flex-col gap-4">
<h1 className="font-bold xs:text-2xl lg:text-4xl">{unitDetail?.title ?? "Unit"}</h1>
<p className="max-w-2xl xs:text-sm lg:text-lg">{unitDetail?.description ?? ""}</p>
{unitDetail?.duration_seconds > 0 && (
<div className="[&_svg]:size-4 flex gap-1.5 items-center">
<Timer />
{formatDuration(unitDetail.duration_seconds)}
</div>
)}
<div className="w-fit">
{contentNotReady ? (
<div className="flex items-center gap-2 rounded-lg border bg-muted px-4 py-2.5 text-sm text-muted-foreground">
<Hourglass className="size-4 shrink-0" />
This unit is currently being prepared. Please check back later.
</div>
) : (
<Button
className="w-fit bg-blue-500"
onClick={() => navigate(`/units/${uuid}/read`)}
>
{hasCompleted
? <><CheckCheck /> Start Again</>
: <><SendHorizonal /> Start Learning</>
}
</Button>
)}
</div>
</div>
</div>
</div>
</div>
{/* Body */}
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-3 xs:-mt-5 lg:-mt-0">
<div className="flex flex-col gap-8 max-w-3xl">
<div className="space-y-4">
<div className="font-bold text-2xl">About this unit</div>
<div className="space-y-4 text-muted-foreground lg:text-lg">
<p>{unitDetail?.description ?? ""}</p>
</div>
</div>
{unitDetail && !contentNotReady && (
<div className="space-y-4">
<div className="font-bold text-2xl">Unit content</div>
<UnitContentCard
unitDetail={unitDetail}
onLessonClick={handleLessonClick}
onQuizClick={handleQuizClick}
/>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default UnitDetails;
+493
View File
@@ -0,0 +1,493 @@
import { useState, useCallback, useEffect, useRef } from "react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate, useLocation, useBlocker } from "react-router-dom";
import { House, TableOfContents, ArrowRight, ClipboardList, Lock, CheckCircle2, Circle, Zap, ChevronsLeft, ChevronsRight, Hourglass } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import LessonBlock from "../components/LessonBlock.jsx";
import QuizBlock from "../components/blocks/QuizBlock.jsx";
import { useLibrary } from "@/contexts/ClientLibraryContext";
import { PageMeta } from "@/contexts/MetadataContext";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
// ─── Sidebar (single unit — flat lessons + quiz, no unit-accordion nesting) ──
const SidebarContent = ({
lessons, quiz, selectedLessonId, selectedQuizId,
onLessonClick, onQuizClick, loading,
}) => (
<ScrollArea className="h-full p-3 md:p-4">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-2 px-2">
Unit Content
</p>
{loading ? (
<div className="space-y-2 px-2 pt-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full rounded-md" />
))}
</div>
) : (
<ul className="space-y-0.5">
{lessons.map((lesson) => (
<li
key={lesson.lesson_id}
onClick={() => onLessonClick(lesson)}
className={`flex items-center gap-2 pl-3 pr-3 py-1.5 text-sm rounded-md hover:bg-muted-foreground/10 hover:text-foreground cursor-pointer transition-colors ${selectedLessonId === lesson.lesson_id
? "bg-muted-foreground/10 text-foreground font-medium"
: "text-muted-foreground"
}`}
>
{lesson.status === "completed"
? <CheckCircle2 className="size-3.5 text-emerald-500 shrink-0" />
: <Circle className="size-3.5 text-muted-foreground/30 shrink-0" />
}
<span className="truncate lg:w-44">{lesson.title}</span>
</li>
))}
{quiz && (
<li
onClick={onQuizClick}
className={`flex items-center gap-2 pl-3 pr-3 py-1.5 text-sm rounded-md cursor-pointer transition-colors ${selectedQuizId
? "bg-muted-foreground/10 text-foreground font-medium"
: quiz.has_passed
? "text-emerald-600 dark:text-emerald-400 hover:bg-muted-foreground/10"
: "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"
}`}
>
{quiz.has_passed
? <CheckCircle2 className="size-3.5 text-emerald-500 shrink-0" />
: <ClipboardList className="size-3.5 shrink-0" />
}
{quiz.title || "Quiz"}
</li>
)}
</ul>
)}
<ScrollBar orientation="vertical" />
</ScrollArea>
);
// ─── UnitReader ─────────────────────────────────────────────────────────────
const UnitReader = () => {
const { uuid } = useParams();
const navigate = useNavigate();
const location = useLocation();
const {
unitDetail, unitDetailLoading, unitBlocked, getUnitDetail, resetUnitDetail,
lesson, lessonLoading, getLesson, resetLesson,
quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz, saveUnitQuizDraft,
upsertLessonProgress,
} = useLibrary();
// Tracks which lessons have been marked completed this session to avoid duplicate calls
const completedSessionRef = useRef(new Set());
// ── Local UI state ──────────────────────────────────────────────────────
const [selectedLessonId, setSelectedLessonId] = useState(null);
const [selectedQuizId, setSelectedQuizId] = useState(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true);
// ── Session guard — block navigation while a quiz is in progress ─────────
const quizActiveRef = useRef(false);
const [quizSessionActive, setQuizSessionActive] = useState(false);
const [pendingNav, setPendingNav] = useState(null);
const setQuizActive = useCallback((active) => {
quizActiveRef.current = active;
setQuizSessionActive(active);
}, []);
const blocker = useBlocker(
({ currentLocation, nextLocation }) =>
quizSessionActive && currentLocation.pathname !== nextLocation.pathname
);
useEffect(() => {
if (blocker.state !== "blocked") return;
setPendingNav(() => () => blocker.proceed());
}, [blocker.state]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!quizSessionActive) return;
const handler = (e) => { e.preventDefault(); e.returnValue = ''; };
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [quizSessionActive]);
// ── Flatten content: lessons + optional quiz ──────────────────────────
const lessons = unitDetail?.lessons ?? [];
const allContent = [
...lessons.map((l) => ({ type: "lesson", lesson: l })),
...(unitDetail?.quiz ? [{ type: "quiz", quiz: unitDetail.quiz }] : []),
];
// ── Scroll progress ────────────────────────────────────────────────────
const [scrollProgress, setScrollProgress] = useState(0);
useEffect(() => {
setScrollProgress(0);
window.scrollTo(0, 0);
}, [selectedLessonId, selectedQuizId]);
useEffect(() => {
const handleScroll = () => {
const scrollTop = window.scrollY;
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
if (scrollHeight <= 0) { setScrollProgress(100); return; }
setScrollProgress(Math.round((scrollTop / scrollHeight) * 100));
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
// ── Mark lesson completed when user scrolls to the bottom ─────────────
useEffect(() => {
if (scrollProgress < 100 || !selectedLessonId || !lesson?.uuid) return;
if (completedSessionRef.current.has(selectedLessonId)) return;
const stub = lessons.find((l) => l.lesson_id === selectedLessonId);
if (stub?.status === 'completed') return;
completedSessionRef.current.add(selectedLessonId);
upsertLessonProgress(lesson.uuid, 'completed', uuid).then((result) => {
if (result?.unit && unitDetail && lessons.every((l) => l.lesson_id === selectedLessonId || l.status === 'completed')) {
toast.success('Unit complete!', { duration: 4000 });
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollProgress]);
// ── Fetch unit + reset on unmount ──────────────────────────────────────
useEffect(() => {
getUnitDetail(uuid);
return () => {
resetUnitDetail();
resetLesson();
resetQuiz();
completedSessionRef.current.clear();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uuid]);
// ── Auto-load lesson once unit data is available ───────────────────────
useEffect(() => {
if (!unitDetail || selectedLessonId || selectedQuizId) return;
const { lessonId, quizId } = location.state ?? {};
if (quizId && unitDetail.quiz) {
setSelectedQuizId(unitDetail.quiz.quiz_id);
getUnitQuiz(uuid);
return;
}
if (lessonId) {
const target = lessons.find((l) => l.lesson_id === lessonId);
if (target) {
setSelectedLessonId(target.lesson_id);
getLesson(target.uuid);
return;
}
}
const firstIncomplete = lessons.find((l) => l.status !== 'completed') ?? lessons[0];
if (firstIncomplete) {
setSelectedLessonId(firstIncomplete.lesson_id);
getLesson(firstIncomplete.uuid);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [unitDetail]);
// ── Breadcrumbs ─────────────────────────────────────────────────────────
const currentLessonStub = lessons.find((l) => l.lesson_id === selectedLessonId) ?? null;
const pageTitle = (() => {
if (!unitDetail) return undefined;
if (selectedQuizId) return `${unitDetail.title} – Quiz - STARR`;
const lessonTitle = lesson?.title ?? currentLessonStub?.title;
if (selectedLessonId && lessonTitle) return `${lessonTitle} - STARR`;
return `${unitDetail.title} - STARR`;
})();
const breadcrumbItemsFull = [
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
{ label: "Units", to: `/units` },
{ label: unitDetail?.title ?? "Unit", to: `/units/${uuid}` },
{ label: selectedQuizId ? "Quiz" : (currentLessonStub?.title ?? "Select a lesson") },
];
const breadcrumbItemsMobile = [
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
{ label: selectedQuizId ? "Quiz" : (currentLessonStub?.title ?? unitDetail?.title ?? "Unit") },
];
// ── Session guard helpers ──────────────────────────────────────────────
const handleConfirmNav = useCallback(() => {
const fn = pendingNav;
setPendingNav(null);
quizActiveRef.current = false;
setQuizSessionActive(false);
if (blocker.state === "blocked") blocker.proceed();
else fn?.();
}, [pendingNav, blocker]);
const handleCancelNav = useCallback(() => {
setPendingNav(null);
if (blocker.state === "blocked") blocker.reset();
}, [blocker]);
// ── Lesson click ───────────────────────────────────────────────────────
const handleLessonClick = useCallback(async (lessonStub) => {
if (lessonStub.lesson_id === selectedLessonId) { setSidebarOpen(false); return; }
const doNav = async () => {
setSelectedLessonId(lessonStub.lesson_id);
setSelectedQuizId(null);
resetQuiz();
setSidebarOpen(false);
await getLesson(lessonStub.uuid);
};
if (quizActiveRef.current) { setPendingNav(() => doNav); return; }
await doNav();
}, [selectedLessonId, getLesson, resetQuiz]);
// ── Quiz click ─────────────────────────────────────────────────────────
const handleQuizClick = useCallback(async () => {
if (selectedQuizId) { setSidebarOpen(false); return; }
const doNav = async () => {
setSelectedQuizId(unitDetail.quiz.quiz_id);
setSelectedLessonId(null);
resetLesson();
setSidebarOpen(false);
await getUnitQuiz(uuid);
};
if (quizActiveRef.current) { setPendingNav(() => doNav); return; }
await doNav();
}, [selectedQuizId, uuid, getUnitQuiz, resetLesson, unitDetail]);
const handleQuizDraft = useCallback((answers) => {
saveUnitQuizDraft(uuid, selectedQuizId, answers);
}, [uuid, selectedQuizId, saveUnitQuizDraft]);
// ── Next content item ──────────────────────────────────────────────────
const getNextContent = useCallback(() => {
const idx = allContent.findIndex((item) =>
(item.type === "lesson" && item.lesson.lesson_id === selectedLessonId) ||
(item.type === "quiz" && selectedQuizId)
);
return idx !== -1 && idx < allContent.length - 1 ? allContent[idx + 1] : null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedLessonId, selectedQuizId, allContent]);
const nextContent = getNextContent();
const nextLabel = nextContent
? nextContent.type === "lesson" ? nextContent.lesson.title : (nextContent.quiz.title || "Quiz")
: null;
const showSidebarContent = desktopSidebarOpen && !quizSessionActive;
const handleNextContentClick = () => {
if (!nextContent) return;
if (nextContent.type === "lesson") handleLessonClick(nextContent.lesson);
else handleQuizClick();
};
// ── Access blocked (403 from getUnitDetail) ──────────────────────────────
if (unitBlocked) {
return (
<div className="flex flex-col items-center gap-6 py-32 text-center px-4">
<div className="h-16 w-16 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<Lock className="size-7 text-amber-500" />
</div>
<div className="space-y-2 max-w-sm">
<h2 className="text-xl font-semibold">Premium / Exclusive Content</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
To access this unit, subscribe to one of our available tier plans.
</p>
</div>
<Button onClick={() => navigate('/plans')} className="gap-1.5">
<Zap className="size-4" /> View Available Plans
</Button>
</div>
);
}
// ── Content not ready ──────────────────────────────────────────────────
if (!unitDetailLoading && unitDetail && !unitDetail.duration_seconds) {
return (
<div className="flex flex-col items-center gap-6 py-32 text-center px-4">
<div className="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
<Hourglass className="size-7 text-muted-foreground" />
</div>
<div className="space-y-2 max-w-sm">
<h2 className="text-xl font-semibold">Unit Not Yet Available</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
This unit is currently being prepared. Please check back later.
</p>
</div>
<Button variant="outline" onClick={() => navigate(`/units/${uuid}`)} className="gap-1.5">
Back to Unit Details
</Button>
</div>
);
}
return (
<>
<PageMeta title={pageTitle} />
{/* ── Session-guard dialog ── */}
<ResponsiveModal
open={!!pendingNav || blocker.state === "blocked"}
onOpenChange={(open) => !open && handleCancelNav()}
title="Leave Quiz?"
description=""
footer={
<>
<Button variant="outline" onClick={handleCancelNav}>Stay</Button>
<Button variant="destructive" onClick={handleConfirmNav}>Leave Anyway</Button>
</>
}
>
<div className="space-y-3 text-sm text-muted-foreground">
<p>
You have an ongoing <strong className="text-foreground">quiz</strong> session in progress.
Leaving now will not submit your answers — your session will remain open.
</p>
</div>
</ResponsiveModal>
{/* ── Up next floating button ── */}
{scrollProgress >= 100 && nextContent && !selectedQuizId && (
<div className="fixed bottom-6 right-6 z-20 animate-in fade-in slide-in-from-bottom-2 duration-300">
<div
onClick={handleNextContentClick}
className="flex items-center gap-3 bg-card border rounded-xl dark:hover:border-blue-500 dark:hover:shadow-blue-500 px-4 py-3 shadow-xl hover:bg-muted transition-colors text-sm font-medium cursor-pointer select-none"
>
<div className="flex flex-col items-start">
<span className="text-sm text-muted-foreground font-normal">Up next</span>
{nextContent.type === "lesson" ? nextContent.lesson.title : (nextContent.quiz.title || "Quiz")}
</div>
<ArrowRight className="size-4 text-muted-foreground" />
</div>
</div>
)}
{selectedQuizId && quiz?.has_passed && !quizSessionActive && nextContent && (
<div className="fixed bottom-6 right-6 z-20 animate-in fade-in slide-in-from-bottom-2 duration-300">
<div
onClick={handleNextContentClick}
className="flex items-center gap-3 bg-card border rounded-xl dark:hover:border-blue-500 dark:hover:shadow-blue-500 px-4 py-3 shadow-xl hover:bg-muted transition-colors text-sm font-medium cursor-pointer select-none"
>
<div className="flex flex-col items-start">
<span className="text-sm text-muted-foreground font-normal">Up next</span>
{nextContent.type === "lesson" ? nextContent.lesson.title : (nextContent.quiz.title || "Quiz")}
</div>
<ArrowRight className="size-4 text-muted-foreground" />
</div>
</div>
)}
{/* ── Top sticky bar ── */}
<div className="fixed top-[67px] left-0 right-0 z-40 bg-card border-b py-3 px-4 md:px-6">
<div className="w-full flex items-center justify-between gap-2">
<div className="flex items-center gap-2 justify-between w-full">
<div className="hidden sm:block min-w-0">
<AppBreadcrumb items={breadcrumbItemsFull} />
</div>
<div className="sm:hidden min-w-0 truncate">
<AppBreadcrumb items={breadcrumbItemsMobile} />
</div>
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="icon" className="lg:hidden flex-shrink-0" aria-label="Open unit content">
<TableOfContents className="size-4" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0 w-80">
<SidebarContent
lessons={lessons}
quiz={unitDetail?.quiz}
selectedLessonId={selectedLessonId}
selectedQuizId={selectedQuizId}
onLessonClick={handleLessonClick}
onQuizClick={handleQuizClick}
loading={unitDetailLoading}
/>
</SheetContent>
</Sheet>
</div>
</div>
{lesson && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-border">
<div
className="h-full bg-primary transition-all duration-150 ease-out"
style={{ width: `${scrollProgress}%` }}
/>
</div>
)}
</div>
{/* ── Desktop sidebar ── */}
<div className="hidden lg:flex flex-row fixed top-[112px] bottom-0 left-0 z-30 bg-muted border-r">
<div className="w-14 shrink-0 flex flex-col items-center pt-3">
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => setDesktopSidebarOpen((prev) => !prev)}
disabled={quizSessionActive}
title={showSidebarContent ? "Collapse sidebar" : "Expand sidebar"}
>
{showSidebarContent ? <ChevronsLeft className="size-4" /> : <ChevronsRight className="size-4" />}
</Button>
</div>
{showSidebarContent && (
<div className="w-[266px] border-l overflow-hidden">
<SidebarContent
lessons={lessons}
quiz={unitDetail?.quiz}
selectedLessonId={selectedLessonId}
selectedQuizId={selectedQuizId}
onLessonClick={handleLessonClick}
onQuizClick={handleQuizClick}
loading={unitDetailLoading}
/>
</div>
)}
</div>
{/* ── Main content ── */}
<div className={`mt-32 ${showSidebarContent ? "lg:ml-80" : "lg:ml-14"} p-4 md:p-6 min-h-screen`}>
<div className="relative w-full h-full">
{selectedQuizId ? (
<QuizBlock
quiz={quiz}
loading={quizLoading}
onDraft={handleQuizDraft}
onSubmit={async (answers) => {
const result = await submitUnitQuiz(uuid, selectedQuizId, answers);
await getUnitDetail(uuid);
return result;
}}
onRetake={() => getUnitQuiz(uuid)}
onActiveChange={setQuizActive}
onNextContent={nextContent ? handleNextContentClick : undefined}
nextLabel={nextLabel}
/>
) : (
<LessonBlock lesson={lesson} loading={lessonLoading} />
)}
</div>
</div>
</>
);
};
export default UnitReader;
+197
View File
@@ -0,0 +1,197 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { House, ChevronLeft, ChevronRight, Layers } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Input } from "@/components/ui/input";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { useLibrary } from "@/contexts/ClientLibraryContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import UnitUpsellModal from "../components/UnitUpsellModal";
import { UnitCard, UnitCardSkeleton } from "../components/UnitCard";
import { PageMeta } from "@/contexts/MetadataContext";
const ITEMS_PER_PAGE = 10;
// ─── Pagination ───────────────────────────────────────────────────────────────
const Pagination = ({ currentPage, totalPages, totalItems, itemsPerPage, onPageChange }) => {
const start = (currentPage - 1) * itemsPerPage + 1;
const end = Math.min(currentPage * itemsPerPage, totalItems);
const getPages = () => {
const pages = [];
if (totalPages <= 5) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
if (currentPage > 3) pages.push("...");
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) pages.push(i);
if (currentPage < totalPages - 2) pages.push("...");
pages.push(totalPages);
}
return pages;
};
return (
<div className="flex items-center justify-between w-full pt-4 border-t">
<div className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{start}–{end}</span> of{" "}
<span className="font-medium text-foreground">{totalItems}</span> units
</div>
<div className="flex items-center gap-1">
<Button onClick={() => onPageChange(currentPage - 1)} disabled={currentPage === 1} variant="ghost" size="sm">
<ChevronLeft />
</Button>
{getPages().map((page, i) =>
page === "..." ? (
<span key={`ellipsis-${i}`} className="w-7 h-7 flex items-center justify-center text-xs text-muted-foreground">···</span>
) : (
<Button key={page} size="sm" variant={currentPage === page ? "default" : "outline"} onClick={() => onPageChange(page)}>
{page}
</Button>
)
)}
<Button onClick={() => onPageChange(currentPage + 1)} disabled={currentPage === totalPages} variant="ghost" size="sm">
<ChevronRight />
</Button>
</div>
</div>
);
};
// ─── Main Page ────────────────────────────────────────────────────────────────
const UnitsList = () => {
const navigate = useNavigate();
const { units, unitsLoading, getUnits } = useLibrary();
const { tierMap, getTierCategories } = useClientTiers();
const [currentPage, setCurrentPage] = useState(1);
const [search, setSearch] = useState("");
const [lockFilter, setLockFilter] = useState("All");
const [modalOpen, setModalOpen] = useState(false);
const [selectedUnit, setSelectedUnit] = useState(null);
useEffect(() => {
getUnits();
getTierCategories();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filtered = useMemo(() =>
units
.filter((u) => {
const matchSearch = u.title.toLowerCase().includes(search.toLowerCase()) ||
(u.description ?? "").toLowerCase().includes(search.toLowerCase());
const matchLock = lockFilter === "All"
|| (lockFilter === "Unlocked" && !u.is_locked)
|| (lockFilter === "Locked" && u.is_locked);
return matchSearch && matchLock;
})
.sort((a, b) => a.title.localeCompare(b.title)),
[units, search, lockFilter]
);
const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
const paginated = filtered.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
const handleViewDetails = (unit) => {
if (unit.is_locked) {
setSelectedUnit(unit);
setModalOpen(true);
} else {
navigate(`/units/${unit.uuid}`);
}
};
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
{ label: "Units" },
];
return (
<div>
<PageMeta title="Units - STARR" description="Browse standalone units you can start right away." />
<div className="py-24 bg-accent/70 min-h-screen">
<div className="flex flex-col gap-4 justify-between lg:container lg:mx-auto pt-2">
<AppBreadcrumb items={items} />
{/* Search & Filters */}
<div className="fixed top-[67px] left-0 right-0 z-10 bg-card border-b lg:static lg:bg-transparent lg:border-none py-3 xs:px-4 md:px-6 lg:px-0">
<div className="flex items-center xs:flex-col lg:flex-row gap-4">
<Input
placeholder="Search units..."
className="w-full bg-card lg:max-w-64 text-sm"
value={search}
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
/>
<Select value={lockFilter} onValueChange={(v) => { setLockFilter(v); setCurrentPage(1); }}>
<SelectTrigger className="w-full lg:w-48 bg-card">
<SelectValue placeholder="Access" />
</SelectTrigger>
<SelectContent>
<SelectItem value="All">All Units</SelectItem>
<SelectItem value="Unlocked">Unlocked</SelectItem>
<SelectItem value="Locked">Locked</SelectItem>
</SelectContent>
</Select>
<Select value="units" onValueChange={(v) => { if (v === "courses") navigate("/course"); }}>
<SelectTrigger className="w-full lg:w-48 bg-card">
<SelectValue placeholder="Browse" />
</SelectTrigger>
<SelectContent>
<SelectItem value="courses">Courses</SelectItem>
<SelectItem value="units">Units</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Unit Grid */}
{unitsLoading ? (
<div className="xs:pt-12 lg:pt-0 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => <UnitCardSkeleton key={i} />)}
</div>
) : paginated.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<Layers className="size-40 text-primary" />
<p className="text-md">No units found</p>
</div>
) : (
<div className="xs:pt-12 lg:pt-0 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mb-4 xs:px-4 lg:px-0">
{paginated.map((unit) => (
<UnitCard
key={unit.unit_id}
unit={unit}
onViewDetails={handleViewDetails}
/>
))}
</div>
)}
{!unitsLoading && filtered.length > ITEMS_PER_PAGE && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={filtered.length}
itemsPerPage={ITEMS_PER_PAGE}
onPageChange={setCurrentPage}
/>
)}
</div>
</div>
<UnitUpsellModal
open={modalOpen}
onOpenChange={setModalOpen}
unit={selectedUnit}
tierMap={tierMap}
/>
</div>
);
};
export default UnitsList;
@@ -6,6 +6,9 @@ import { Navigate, Outlet } from "react-router-dom"
import ScrollToTop from '@/components/generic/ScrollToTop'
import CourseDetails from '../pages/CourseDetails'
import UnitList from '../pages/UnitList'
import UnitsList from '../pages/UnitsList'
import UnitDetails from '../pages/UnitDetails'
import UnitReader from '../pages/UnitReader'
import { Fragment } from 'react'
import GroupList from '../pages/GroupList'
import ViewTaskDetails from '../pages/ViewTaskDetails'
@@ -87,6 +90,21 @@ export const ClientRoutes = {
],
},
{
path: 'units',
element: <Outlet />,
children: [
{ index: true, element: <UnitsList /> },
{
path: ':uuid', element: <Outlet />,
children: [
{ index: true, element: <UnitDetails /> },
{ path: 'read', element: <UnitReader />, handle: { showFooter: false } },
],
},
],
},
{
path: 'group', element: <Outlet />,
children: [