mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
wip: course layout and func to context
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
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 { buildDataColumns, columnPinning } from "../../config/courses/columns.config";
|
||||
import { buildToolbarActions } from "../../config/courses/toolbar.config";
|
||||
import { buildSelectionActions } from "../../config/courses/selection.config";
|
||||
import { buildRowActions } from "../../config/courses/rowActions.config";
|
||||
|
||||
import { getTimestamp } from "@/utils/timestamp.util";
|
||||
|
||||
export default function CoursesTable() {
|
||||
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||
|
||||
const tableRefsRef = useRef({
|
||||
getFilters: () => [],
|
||||
getSort: () => [],
|
||||
resetSelection: () => { },
|
||||
setFilters: () => { },
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { courses, attributes, pagination, setPagination, loading, fetchCourses, deleteCourse, } = useCourses();
|
||||
|
||||
const handleRefsReady = (refs) => {
|
||||
tableRefsRef.current = refs;
|
||||
};
|
||||
|
||||
const exportConfig = {
|
||||
allData: courses,
|
||||
attributes,
|
||||
filename: `${getTimestamp()}_Courses`,
|
||||
sheetName: "Courses",
|
||||
};
|
||||
|
||||
const rowActions = useMemo(() => buildRowActions({
|
||||
onView: (row) => navigate(`/admin/courses/${row.course_id}`),
|
||||
onEdit: (row) => navigate(`/admin/courses/${row.course_id}/edit`),
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
}), []);
|
||||
|
||||
const toolbarActions = buildToolbarActions({
|
||||
fetchCourses,
|
||||
pagination,
|
||||
exportConfig,
|
||||
navigate,
|
||||
getFilters: () => tableRefsRef.current.getFilters(),
|
||||
getSort: () => tableRefsRef.current.getSort(),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
const selectionActions = buildSelectionActions({
|
||||
exportConfig,
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildDataColumns(attributes, rowActions),
|
||||
[attributes, rowActions]
|
||||
);
|
||||
|
||||
const handleArchiveSuccess = () => {
|
||||
setArchiveTarget(null);
|
||||
tableRefsRef.current.resetSelection?.();
|
||||
fetchCourses({ page: 1, limit: pagination?.limit ?? 10 });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
title="Courses"
|
||||
data={courses}
|
||||
columns={columns}
|
||||
attributes={attributes}
|
||||
pagination={pagination}
|
||||
setPagination={setPagination}
|
||||
loading={loading}
|
||||
onFetch={fetchCourses}
|
||||
onFetchFilterData={() => []}
|
||||
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="course"
|
||||
emptyMessage="No courses found."
|
||||
/>
|
||||
|
||||
<ArchiveDialog
|
||||
open={!!archiveTarget}
|
||||
onOpenChange={(v) => !v && setArchiveTarget(null)}
|
||||
entity={archiveTarget}
|
||||
entityLabel="Course"
|
||||
getName={(c) => c?.title}
|
||||
onArchive={(c) => deleteCourse(c?.course_id)}
|
||||
loading={loading}
|
||||
onSuccess={handleArchiveSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useMemo, useRef, useState, useEffect, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
|
||||
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/courses/lessons/columns.config";
|
||||
import { buildToolbarActions } from "../../config/courses/lessons/toolbar.config";
|
||||
import { buildRowActions } from "../../config/courses/lessons/rowActions.config";
|
||||
|
||||
import { getTimestamp } from "@/utils/timestamp.util";
|
||||
|
||||
export default function LessonsTable({ courseId, unitId }) {
|
||||
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||
|
||||
const tableRefsRef = useRef({
|
||||
getFilters: () => [],
|
||||
getSort: () => [],
|
||||
resetSelection: () => { },
|
||||
setFilters: () => { },
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
lessons, attributes, pagination, setPagination, loading,
|
||||
fetchLessons, deleteLesson,
|
||||
} = useCourses();
|
||||
|
||||
const handleRefsReady = (refs) => {
|
||||
tableRefsRef.current = refs;
|
||||
};
|
||||
|
||||
const exportConfig = {
|
||||
allData: lessons,
|
||||
attributes,
|
||||
filename: `${getTimestamp()}_Lessons`,
|
||||
sheetName: "Lessons",
|
||||
};
|
||||
|
||||
const handleFetch = useCallback(
|
||||
(params) => fetchLessons(courseId, unitId, params),
|
||||
[courseId, unitId]
|
||||
);
|
||||
|
||||
const rowActions = useMemo(() => buildRowActions({
|
||||
onView: (row) => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${row.lesson_id}`),
|
||||
onEdit: (row) => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${row.lesson_id}/edit`),
|
||||
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 columns = useMemo(
|
||||
() => buildDataColumns(attributes, rowActions),
|
||||
[attributes, rowActions]
|
||||
);
|
||||
|
||||
const handleArchiveSuccess = () => {
|
||||
setArchiveTarget(null);
|
||||
tableRefsRef.current.resetSelection?.();
|
||||
fetchLessons(courseId, unitId, { page: 1, limit: pagination?.limit ?? 10 });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
title="Lessons"
|
||||
data={lessons}
|
||||
columns={columns}
|
||||
attributes={attributes}
|
||||
pagination={pagination}
|
||||
setPagination={setPagination}
|
||||
loading={loading}
|
||||
onFetch={handleFetch}
|
||||
onFetchFilterData={() => []}
|
||||
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}
|
||||
recordLabel="lesson"
|
||||
emptyMessage="No lessons found."
|
||||
/>
|
||||
|
||||
<ArchiveDialog
|
||||
open={!!archiveTarget}
|
||||
onOpenChange={(v) => !v && setArchiveTarget(null)}
|
||||
entity={archiveTarget}
|
||||
entityLabel="Lesson"
|
||||
getName={(l) => l?.title}
|
||||
onArchive={(l) => deleteLesson(courseId, unitId, l?.lesson_id)}
|
||||
loading={loading}
|
||||
onSuccess={handleArchiveSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useMemo, useRef, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
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 { buildDataColumns, columnPinning } from "../../config/courses/units/columns.config";
|
||||
import { buildToolbarActions } from "../../config/courses/units/toolbar.config";
|
||||
import { buildRowActions } from "../../config/courses/units/rowActions.config";
|
||||
|
||||
import { getTimestamp } from "@/utils/timestamp.util";
|
||||
|
||||
export default function UnitsTable({ courseId }) {
|
||||
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||
|
||||
const tableRefsRef = useRef({
|
||||
getFilters: () => [],
|
||||
getSort: () => [],
|
||||
resetSelection: () => { },
|
||||
setFilters: () => { },
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
units, attributes, pagination, setPagination, loading,
|
||||
fetchUnits, deleteUnit,
|
||||
} = useCourses();
|
||||
|
||||
const handleRefsReady = (refs) => {
|
||||
tableRefsRef.current = refs;
|
||||
};
|
||||
|
||||
const exportConfig = {
|
||||
allData: units,
|
||||
attributes,
|
||||
filename: `${getTimestamp()}_Units`,
|
||||
sheetName: "Units",
|
||||
};
|
||||
|
||||
const handleFetch = useCallback(
|
||||
(params) => fetchUnits(courseId, params),
|
||||
[courseId]
|
||||
);
|
||||
|
||||
const rowActions = useMemo(() => buildRowActions({
|
||||
onView: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}`),
|
||||
onEdit: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/edit`),
|
||||
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 columns = useMemo(
|
||||
() => buildDataColumns(attributes, rowActions),
|
||||
[attributes, rowActions]
|
||||
);
|
||||
|
||||
const handleArchiveSuccess = () => {
|
||||
setArchiveTarget(null);
|
||||
tableRefsRef.current.resetSelection?.();
|
||||
fetchUnits(courseId, { page: 1, limit: pagination?.limit ?? 10 });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
title="Units"
|
||||
data={units}
|
||||
columns={columns}
|
||||
attributes={attributes}
|
||||
pagination={pagination}
|
||||
setPagination={setPagination}
|
||||
loading={loading}
|
||||
onFetch={handleFetch}
|
||||
onFetchFilterData={() => []}
|
||||
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}
|
||||
recordLabel="unit"
|
||||
emptyMessage="No units found."
|
||||
/>
|
||||
|
||||
<ArchiveDialog
|
||||
open={!!archiveTarget}
|
||||
onOpenChange={(v) => !v && setArchiveTarget(null)}
|
||||
entity={archiveTarget}
|
||||
entityLabel="Unit"
|
||||
getName={(u) => u?.title}
|
||||
onArchive={(u) => deleteUnit(courseId, u?.unit_id)}
|
||||
loading={loading}
|
||||
onSuccess={handleArchiveSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const columnPinning = {
|
||||
left: ["select", "title"],
|
||||
right: ["actions"],
|
||||
};
|
||||
|
||||
export function buildDataColumns(attributes, rowActions) {
|
||||
const base = [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Title",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.title}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Description",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-sm truncate max-w-xs block">
|
||||
{row.original.description ?? "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "order",
|
||||
header: "Order",
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{row.original.order}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return rowActions ? [...base, rowActions] : base;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const columnPinning = {
|
||||
left: ["select", "title"],
|
||||
right: ["actions"],
|
||||
};
|
||||
|
||||
export function buildDataColumns(attributes, rowActions) {
|
||||
const base = [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Title",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.title}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Description",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-sm truncate max-w-xs block">
|
||||
{row.original.description ?? "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "order",
|
||||
header: "Order",
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{row.original.order}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "page",
|
||||
header: "Content",
|
||||
cell: ({ row }) => {
|
||||
const blocks = row.original.page?.blocks ?? [];
|
||||
return blocks.length ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{blocks.length} block{blocks.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">No content</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return rowActions ? [...base, rowActions] : base;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// config/courses/lessons/rowActions.config.jsx
|
||||
|
||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ onView, onEdit, onArchive }) {
|
||||
return {
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onView(row.original)}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(row.original)}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onArchive(row.original)}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// config/courses/lessons/toolbar.config.jsx
|
||||
|
||||
import { Plus, RefreshCw, Download } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildToolbarActions({
|
||||
fetchLessons,
|
||||
pagination,
|
||||
exportConfig,
|
||||
navigate,
|
||||
courseId,
|
||||
unitId,
|
||||
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/courses/${courseId}/units/${unitId}/lessons/create`
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ onView, onEdit, onArchive }) {
|
||||
return {
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onView(row.original)}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(row.original)}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onArchive(row.original)}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildSelectionActions({ exportConfig, 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(),
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Plus, RefreshCw, Download } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildToolbarActions({
|
||||
fetchCourses,
|
||||
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: () => fetchCourses({
|
||||
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 Course",
|
||||
icon: <Plus className="h-3.5 w-3.5" />,
|
||||
variant: "default",
|
||||
onClick: () => navigate("/admin/courses/create"),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// config/courses/units/columns.config.jsx
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const columnPinning = {
|
||||
left: ["select", "title"],
|
||||
right: ["actions"],
|
||||
};
|
||||
|
||||
export function buildDataColumns(attributes, rowActions) {
|
||||
const base = [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Title",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.title}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Description",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-sm truncate max-w-xs block">
|
||||
{row.original.description ?? "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "order",
|
||||
header: "Order",
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{row.original.order}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return rowActions ? [...base, rowActions] : base;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ onView, onEdit, onArchive }) {
|
||||
return {
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onView(row.original)}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(row.original)}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onArchive(row.original)}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Plus, RefreshCw, Download } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildToolbarActions({
|
||||
fetchUnits,
|
||||
pagination,
|
||||
exportConfig,
|
||||
navigate,
|
||||
courseId,
|
||||
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/courses/${courseId}/units/create`),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, Plus } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import UnitsTable from "../../components/courses/UnitsTable";
|
||||
|
||||
export default function CourseDetail() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId } = useParams();
|
||||
const { fetchCourse, course, loading } = useCourses();
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await fetchCourse(courseId);
|
||||
setInitializing(false);
|
||||
})();
|
||||
}, [courseId]);
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: course?.title ?? "..." },
|
||||
];
|
||||
|
||||
// Replace the loading check
|
||||
if (initializing) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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">
|
||||
|
||||
{/* ── Course header ── */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{course?.title}</h1>
|
||||
{course?.description && (
|
||||
<p className="text-sm text-muted-foreground">{course.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => navigate(`/admin/courses/${courseId}/edit`)}>
|
||||
Edit Course
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Units ── */}
|
||||
<UnitsTable courseId={courseId} />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { House } from "lucide-react";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import CoursesTable from "../../components/courses/CourseTable";
|
||||
|
||||
export default function CourseList() {
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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">
|
||||
<CoursesTable />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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 { House } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
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(),
|
||||
order: z.coerce.number().min(0).default(0),
|
||||
});
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
export default function CreateCourse() {
|
||||
const navigate = useNavigate();
|
||||
const { createCourse, loading } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", order: 0 },
|
||||
});
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: "Create" },
|
||||
];
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const result = await createCourse({ ...data, createdBy: user?.user_id });
|
||||
if (!result) return;
|
||||
navigate("/admin/courses");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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 max-w-2xl">
|
||||
<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 Course</h1>
|
||||
<p className="text-sm text-muted-foreground">Add a new course.</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="Course 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 className="space-y-1.5 max-w-[120px]">
|
||||
<Label htmlFor="order">Order</Label>
|
||||
<Input id="order" type="number" min={0} {...register("order")} />
|
||||
</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 Course
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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, House } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
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(),
|
||||
order: z.coerce.number().min(0).default(0),
|
||||
});
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
export default function CreateLesson() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId } = useParams();
|
||||
const { createLesson, course, unit, loading } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", order: 0 },
|
||||
});
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: course?.title ?? "Course", to: `/admin/courses/${courseId}` },
|
||||
{ label: unit?.title ?? "Unit", to: `/admin/courses/${courseId}/units/${unitId}` },
|
||||
{ label: "Create Lesson" },
|
||||
];
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const result = await createLesson(courseId, unitId, { ...data, createdBy: user?.user_id });
|
||||
if (!result) return;
|
||||
navigate(`/admin/courses/${courseId}/units/${unitId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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 max-w-2xl">
|
||||
<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">Add a new lesson to this unit.</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 className="space-y-1.5 max-w-[120px]">
|
||||
<Label htmlFor="order">Order</Label>
|
||||
<Input id="order" type="number" min={0} {...register("order")} />
|
||||
</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,104 @@
|
||||
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, House } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
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(),
|
||||
order: z.coerce.number().min(0).default(0),
|
||||
});
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
export default function CreateUnit() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId } = useParams();
|
||||
const { createUnit, course, loading } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", order: 0 },
|
||||
});
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: course?.title ?? "Course", to: `/admin/courses/${courseId}` },
|
||||
{ label: "Create Unit" },
|
||||
];
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const result = await createUnit(courseId, { ...data, createdBy: user?.user_id });
|
||||
if (!result) return;
|
||||
navigate(`/admin/courses/${courseId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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 max-w-2xl">
|
||||
<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">Add a new unit to this course.</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 className="space-y-1.5 max-w-[120px]">
|
||||
<Label htmlFor="order">Order</Label>
|
||||
<Input id="order" type="number" min={0} {...register("order")} />
|
||||
</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,118 @@
|
||||
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, House } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
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(),
|
||||
order: z.coerce.number().min(0).default(0),
|
||||
});
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
export default function EditCourse() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId } = useParams();
|
||||
const { fetchCourse, updateCourse, loading } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors, isDirty } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", order: 0 },
|
||||
});
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: "Edit" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await fetchCourse(courseId);
|
||||
const course = res?.data?.data ?? null;
|
||||
if (!course) return;
|
||||
reset({
|
||||
title: course.title ?? "",
|
||||
description: course.description ?? "",
|
||||
order: course.order ?? 0,
|
||||
});
|
||||
})();
|
||||
}, [courseId]);
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
if (!isDirty) return navigate(-1);
|
||||
const result = await updateCourse(courseId, { ...data, updatedBy: user?.user_id });
|
||||
if (!result) return;
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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 max-w-2xl">
|
||||
<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 Course</h1>
|
||||
<p className="text-sm text-muted-foreground">Update course details.</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="Course 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 className="space-y-1.5 max-w-[120px]">
|
||||
<Label htmlFor="order">Order</Label>
|
||||
<Input id="order" type="number" min={0} {...register("order")} />
|
||||
</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 || !isDirty}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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, House } from "lucide-react";
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
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(),
|
||||
order: z.coerce.number().min(0).default(0),
|
||||
});
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
export default function EditLesson() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId, lessonId } = useParams();
|
||||
const { fetchLesson, updateLesson, course, unit, loading } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors, isDirty } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", order: 0 },
|
||||
});
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: course?.title ?? "Course", to: `/admin/courses/${courseId}` },
|
||||
{ label: unit?.title ?? "Unit", to: `/admin/courses/${courseId}/units/${unitId}` },
|
||||
{ label: "Edit Lesson" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await fetchLesson(courseId, unitId, lessonId);
|
||||
const lesson = res?.data?.data ?? null;
|
||||
if (!lesson) return;
|
||||
reset({
|
||||
title: lesson.title ?? "",
|
||||
description: lesson.description ?? "",
|
||||
order: lesson.order ?? 0,
|
||||
});
|
||||
})();
|
||||
}, [courseId, unitId, lessonId]);
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
if (!isDirty) return navigate(-1);
|
||||
const result = await updateLesson(courseId, unitId, lessonId, {
|
||||
...data,
|
||||
updatedBy: user?.user_id,
|
||||
});
|
||||
if (!result) return;
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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 max-w-2xl">
|
||||
<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">Update lesson details.</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 className="space-y-1.5 max-w-[120px]">
|
||||
<Label htmlFor="order">Order</Label>
|
||||
<Input id="order" type="number" min={0} {...register("order")} />
|
||||
</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 || !isDirty}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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, House } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
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(),
|
||||
order: z.coerce.number().min(0).default(0),
|
||||
});
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
export default function EditUnit() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId } = useParams();
|
||||
const { fetchUnit, updateUnit, course, loading } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors, isDirty } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", order: 0 },
|
||||
});
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: course?.title ?? "Course", to: `/admin/courses/${courseId}` },
|
||||
{ label: "Edit Unit" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await fetchUnit(courseId, unitId);
|
||||
const unit = res?.data?.data ?? null;
|
||||
if (!unit) return;
|
||||
reset({
|
||||
title: unit.title ?? "",
|
||||
description: unit.description ?? "",
|
||||
order: unit.order ?? 0,
|
||||
});
|
||||
})();
|
||||
}, [courseId, unitId]);
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
if (!isDirty) return navigate(-1);
|
||||
const result = await updateUnit(courseId, unitId, { ...data, updatedBy: user?.user_id });
|
||||
if (!result) return;
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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 max-w-2xl">
|
||||
<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">Update unit details.</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 className="space-y-1.5 max-w-[120px]">
|
||||
<Label htmlFor="order">Order</Label>
|
||||
<Input id="order" type="number" min={0} {...register("order")} />
|
||||
</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 || !isDirty}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// modules/admin/pages/courses/LessonDetail.jsx
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, Layout } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export default function LessonDetail() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId, lessonId } = useParams();
|
||||
const { fetchLesson, course, unit, lesson, lessonPage, loading } = useCourses();
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await fetchLesson(courseId, unitId, lessonId);
|
||||
setInitializing(false);
|
||||
})();
|
||||
}, [courseId, unitId, lessonId]);
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: course?.title ?? "Course", to: `/admin/courses/${courseId}` },
|
||||
{ label: unit?.title ?? "Unit", to: `/admin/courses/${courseId}/units/${unitId}` },
|
||||
{ label: lesson?.title ?? "..." },
|
||||
];
|
||||
|
||||
// Replace the loading check
|
||||
if (initializing) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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 max-w-3xl space-y-6">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{lesson?.title}</h1>
|
||||
{lesson?.description && (
|
||||
<p className="text-sm text-muted-foreground">{lesson.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/edit`)}
|
||||
>
|
||||
Edit Details
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Page builder card ── */}
|
||||
<div className="rounded-lg border bg-card p-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Layout className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Lesson Content</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{lessonPage?.blocks?.length
|
||||
? `${lessonPage.blocks.length} block${lessonPage.blocks.length !== 1 ? "s" : ""}`
|
||||
: "No content yet"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate(
|
||||
`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page`
|
||||
)}
|
||||
>
|
||||
{lessonPage?.blocks?.length ? "Edit Content" : "Add Content"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, Save } from "lucide-react";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { BlockList, DEFAULT_CONTENT } from "@/components/generic/BlockList";
|
||||
import { AddBlockMenu } from "@/components/generic/AddBlockMenu";
|
||||
|
||||
function makeBlock(type) {
|
||||
return {
|
||||
id: nanoid(),
|
||||
type,
|
||||
content: { ...DEFAULT_CONTENT[type] },
|
||||
};
|
||||
}
|
||||
|
||||
export default function LessonPageBuilder() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId, lessonId } = useParams();
|
||||
const { fetchLesson, saveLessonPage, course, unit, lesson, lessonPage, loading } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [blocks, setBlocks] = useState([]);
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await fetchLesson(courseId, unitId, lessonId);
|
||||
setInitializing(false);
|
||||
})();
|
||||
}, [courseId, unitId, lessonId]);
|
||||
|
||||
// ── Populate blocks from saved page ───────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (lessonPage?.blocks?.length) {
|
||||
setBlocks(lessonPage.blocks);
|
||||
}
|
||||
}, [lessonPage]);
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: course?.title ?? "Course", to: `/admin/courses/${courseId}` },
|
||||
{ label: unit?.title ?? "Unit", to: `/admin/courses/${courseId}/units/${unitId}` },
|
||||
{ label: lesson?.title ?? "Lesson", to: `/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}` },
|
||||
{ label: "Page Builder" },
|
||||
];
|
||||
|
||||
// ── Block actions ─────────────────────────────────────────────────────────
|
||||
|
||||
const addBlock = (type) =>
|
||||
setBlocks((prev) => [...prev, makeBlock(type)]);
|
||||
|
||||
const updateBlock = (id, content) =>
|
||||
setBlocks((prev) => prev.map((b) => (b.id === id ? { ...b, content } : b)));
|
||||
|
||||
const deleteBlock = (id) =>
|
||||
setBlocks((prev) => prev.filter((b) => b.id !== id));
|
||||
|
||||
const moveBlock = (id, direction) => {
|
||||
setBlocks((prev) => {
|
||||
const index = prev.findIndex((b) => b.id === id);
|
||||
const swapIndex = direction === "up" ? index - 1 : index + 1;
|
||||
if (swapIndex < 0 || swapIndex >= prev.length) return prev;
|
||||
const next = [...prev];
|
||||
[next[index], next[swapIndex]] = [next[swapIndex], next[index]];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleSave = async () => {
|
||||
const result = await saveLessonPage(courseId, unitId, lessonId, {
|
||||
blocks,
|
||||
updatedBy: user?.user_id,
|
||||
});
|
||||
if (!result) return;
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
if (initializing) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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 max-w-3xl space-y-6 pb-10">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Page Builder</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{lesson?.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Blocks ── */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">
|
||||
Content Blocks
|
||||
{blocks.length > 0 && (
|
||||
<span className="ml-2 text-xs text-muted-foreground font-normal">
|
||||
{blocks.length} block{blocks.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BlockList
|
||||
blocks={blocks}
|
||||
onUpdate={updateBlock}
|
||||
onMove={moveBlock}
|
||||
onDelete={deleteBlock}
|
||||
/>
|
||||
|
||||
<AddBlockMenu onAdd={addBlock} />
|
||||
</div>
|
||||
|
||||
{/* ── Actions ── */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={loading}>
|
||||
{loading
|
||||
? <Spinner className="h-4 w-4 mr-2" />
|
||||
: <Save className="h-4 w-4 mr-2" />
|
||||
}
|
||||
Save Page
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import LessonsTable from "../../components/courses/LessonsTable";
|
||||
|
||||
export default function UnitDetail() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId } = useParams();
|
||||
const { fetchUnit, course, unit, loading } = useCourses();
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await fetchUnit(courseId, unitId);
|
||||
setInitializing(false);
|
||||
})();
|
||||
}, [courseId, unitId]);
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: course?.title ?? "Course", to: `/admin/courses/${courseId}` },
|
||||
{ label: unit?.title ?? "..." },
|
||||
];
|
||||
|
||||
// Replace the loading check
|
||||
if (initializing) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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">
|
||||
|
||||
{/* ── Unit header ── */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{unit?.title}</h1>
|
||||
{unit?.description && (
|
||||
<p className="text-sm text-muted-foreground">{unit.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/edit`)}>
|
||||
Edit Unit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Lessons ── */}
|
||||
<LessonsTable courseId={courseId} unitId={unitId} />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,17 @@ import ViewVideoAsset from "../pages/assets/ViewVideoAsset";
|
||||
import ViewDocumentAsset from "../pages/assets/ViewDocumentAsset";
|
||||
import AssetList from '../pages/assets/AssetList'
|
||||
import EditAsset from '../pages/assets/EditAsset'
|
||||
import CourseList from '../pages/courses/CourseList'
|
||||
import CreateCourse from '../pages/courses/CreateCourse'
|
||||
import EditCourse from '../pages/courses/EditCourse'
|
||||
import CourseDetail from '../pages/courses/CourseDetail'
|
||||
import CreateUnit from '../pages/courses/CreateUnit'
|
||||
import UnitDetail from '../pages/courses/UnitDetail'
|
||||
import CreateLesson from '../pages/courses/CreateLesson'
|
||||
import LessonDetail from '../pages/courses/LessonDetail'
|
||||
import LessonPageBuilder from '../pages/courses/LessonPageBuilder'
|
||||
import EditUnit from '../pages/courses/EditUnit'
|
||||
import EditLesson from '../pages/courses/EditLesson'
|
||||
|
||||
export const AdminRoutes = {
|
||||
element: <ProtectedRoute allowedRoles={['admin']} />,
|
||||
@@ -74,9 +85,29 @@ export const AdminRoutes = {
|
||||
{ path: 'view/document/:assetId', element: <ViewDocumentAsset /> },
|
||||
{ path: 'edit/:assetId', element: <EditAsset /> },
|
||||
],
|
||||
}
|
||||
},
|
||||
|
||||
// Courses
|
||||
{
|
||||
path: 'courses',
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <CourseList /> },
|
||||
{ path: 'create', element: <CreateCourse /> },
|
||||
{ path: ':courseId', element: <CourseDetail /> },
|
||||
{ path: ':courseId/edit', element: <EditCourse /> },
|
||||
{ path: ':courseId/units/create', element: <CreateUnit /> },
|
||||
{ path: ':courseId/units/:unitId', element: <UnitDetail /> },
|
||||
{ path: ':courseId/units/:unitId/edit', element: <EditUnit /> },
|
||||
{ path: ':courseId/units/:unitId/lessons/create', element: <CreateLesson /> },
|
||||
{ path: ':courseId/units/:unitId/lessons/:lessonId', element: <LessonDetail /> },
|
||||
{ path: ':courseId/units/:unitId/lessons/:lessonId/edit', element: <EditLesson /> },
|
||||
{ path: ':courseId/units/:unitId/lessons/:lessonId/page', element: <LessonPageBuilder /> },
|
||||
]
|
||||
},
|
||||
|
||||
// Add here
|
||||
]
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user