diff --git a/src/components/generic/AddBlockMenu.jsx b/src/components/generic/AddBlockMenu.jsx
new file mode 100644
index 0000000..e57ae86
--- /dev/null
+++ b/src/components/generic/AddBlockMenu.jsx
@@ -0,0 +1,75 @@
+// components/generic/CMS/AddBlockMenu.jsx
+
+import { Plus, Type, Image, ImagePlay, Video, VideoIcon } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+const BLOCK_TYPES = [
+ {
+ type: "text",
+ label: "Text",
+ description: "A rich text block",
+ icon: ,
+ },
+ {
+ type: "image",
+ label: "Image",
+ description: "A single image",
+ icon: ,
+ },
+ {
+ type: "text-image",
+ label: "Text + Image",
+ description: "Text beside an image",
+ icon: ,
+ },
+ {
+ type: "video",
+ label: "Video",
+ description: "A single video",
+ icon: ,
+ },
+ {
+ type: "text-video",
+ label: "Text + Video",
+ description: "Text beside a video",
+ icon: ,
+ },
+];
+
+export function AddBlockMenu({ onAdd }) {
+ return (
+
+
+
+
+
+ Choose a block type
+
+ {BLOCK_TYPES.map(({ type, label, description, icon }) => (
+ onAdd(type)}
+ className="flex items-start gap-3 py-2 cursor-pointer"
+ >
+ {icon}
+
+ {label}
+ {description}
+
+
+ ))}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/generic/AssetPickerSheet.jsx b/src/components/generic/AssetPickerSheet.jsx
new file mode 100644
index 0000000..1fe91b4
--- /dev/null
+++ b/src/components/generic/AssetPickerSheet.jsx
@@ -0,0 +1,195 @@
+import { useEffect, useState } from "react";
+import { Search, CheckCircle2 } from "lucide-react";
+
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetDescription,
+} from "@/components/ui/sheet";
+import { Input } from "@/components/ui/input";
+import { Spinner } from "@/components/ui/spinner";
+import { Badge } from "@/components/ui/badge";
+
+import { useAssets } from "@/contexts/AdminAssetsContext";
+
+// ─── Empty ────────────────────────────────────────────────────────────────────
+
+function EmptyState({ fileType }) {
+ return (
+
+
+ No {fileType} assets found.
+
+
+ );
+}
+
+// ─── Asset Card ───────────────────────────────────────────────────────────────
+
+function AssetCard({ asset, selected, onSelect }) {
+ const thumb = asset.thumbnail_url ?? asset.file_url;
+
+ return (
+
+ );
+}
+
+// ─── Sheet ────────────────────────────────────────────────────────────────────
+
+export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
+ const { fetchAssets, assets, pagination, loading } = useAssets();
+ const [search, setSearch] = useState("");
+ const [page, setPage] = useState(1);
+ const [selected, setSelected] = useState(null);
+
+ const LIMIT = 12;
+
+ // ── Fetch on open / search / page change ──────────────────────────────────
+ useEffect(() => {
+ if (!open) return;
+ fetchAssets({
+ page,
+ limit: LIMIT,
+ filters: [
+ ...(fileType ? [{ id: "file_type", value: [fileType] }] : []),
+ ...(search ? [{ id: "display_name", value: [search] }] : []),
+ ],
+ });
+ }, [open, page, search, fileType]);
+
+ // ── Reset on close ────────────────────────────────────────────────────────
+ useEffect(() => {
+ if (!open) {
+ setSearch("");
+ setPage(1);
+ setSelected(null);
+ }
+ }, [open]);
+
+ const handleSelect = (asset) => {
+ setSelected(asset.asset_id);
+ onSelect(asset);
+ onOpenChange(false);
+ };
+
+ const label = fileType
+ ? `${fileType.charAt(0).toUpperCase()}${fileType.slice(1)}s`
+ : "Assets";
+
+ return (
+
+
+
+ {/* ── Header ── */}
+
+ Select {label}
+
+ Click an asset to attach it.
+
+
+
+ {/* ── Search ── */}
+
+
+
+ { setSearch(e.target.value); setPage(1); }}
+ className="pl-9"
+ />
+
+
+
+ {/* ── Grid ── */}
+
+ {loading ? (
+
+
+
+ ) : !assets.length ? (
+
+ ) : (
+
+ {assets.map((asset) => (
+
+ ))}
+
+ )}
+
+
+ {/* ── Pagination ── */}
+ {pagination.totalPages > 1 && (
+
+
+ Page {pagination.page} of {pagination.totalPages}
+
+
+
+
+
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/generic/BlockList.jsx b/src/components/generic/BlockList.jsx
new file mode 100644
index 0000000..7e1fe54
--- /dev/null
+++ b/src/components/generic/BlockList.jsx
@@ -0,0 +1,66 @@
+// components/generic/CMS/BlockList.jsx
+
+import { BlockWrapper } from "./BlockWrapper";
+import { TextBlock } from "./Blocks/TextBlock";
+import { ImageBlock } from "./Blocks/ImageBlock";
+import { TextImageBlock } from "./Blocks/TextImageBlock";
+import { VideoBlock } from "./Blocks/VideoBlock";
+import { TextVideoBlock } from "./Blocks/TextVideoBlock";
+
+// ─── Block renderer ───────────────────────────────────────────────────────────
+
+function BlockContent({ type, content, onUpdate }) {
+ switch (type) {
+ case "text": return ;
+ case "image": return ;
+ case "text-image": return ;
+ case "video": return ;
+ case "text-video": return ;
+ default: return Unknown block type.
;
+ }
+}
+
+// ─── Default content per type ─────────────────────────────────────────────────
+
+export const DEFAULT_CONTENT = {
+ "text": { body: "" },
+ "image": { asset_id: null, url: "", alt: "" },
+ "text-image": { body: "", asset_id: null, url: "", alt: "", image_position: "right" },
+ "video": { asset_id: null, url: "", thumbnail_url: "" },
+ "text-video": { body: "", asset_id: null, url: "", thumbnail_url: "", video_position: "right" },
+};
+
+// ─── List ─────────────────────────────────────────────────────────────────────
+
+export function BlockList({ blocks, onUpdate, onMove, onDelete }) {
+ if (!blocks.length) {
+ return (
+
+
No blocks yet.
+
Use the button below to add your first block.
+
+ );
+ }
+
+ return (
+
+ {blocks.map((block, index) => (
+ onMove(block.id, "up")}
+ onMoveDown={() => onMove(block.id, "down")}
+ onDelete={() => onDelete(block.id)}
+ >
+ onUpdate(block.id, updated)}
+ />
+
+ ))}
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/generic/BlockWrapper.jsx b/src/components/generic/BlockWrapper.jsx
new file mode 100644
index 0000000..e88925a
--- /dev/null
+++ b/src/components/generic/BlockWrapper.jsx
@@ -0,0 +1,83 @@
+// components/generic/CMS/BlockWrapper.jsx
+
+import { GripVertical, ChevronUp, ChevronDown, Trash2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+
+const BLOCK_LABELS = {
+ "text": "Text",
+ "image": "Image",
+ "text-image": "Text + Image",
+ "video": "Video",
+ "text-video": "Text + Video",
+};
+
+export function BlockWrapper({
+ type,
+ index,
+ total,
+ onMoveUp,
+ onMoveDown,
+ onDelete,
+ children,
+}) {
+ return (
+
+
+ {/* ── Top toolbar ── */}
+
+
+ {/* Left — drag handle + block type */}
+
+
+
+ {BLOCK_LABELS[type] ?? type}
+
+
+ Block {index + 1}
+
+
+
+ {/* Right — move + delete */}
+
+
+
+
+
+
+
+
+ {/* ── Block content ── */}
+
+ {children}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/generic/Blocks/ImageBlock.jsx b/src/components/generic/Blocks/ImageBlock.jsx
new file mode 100644
index 0000000..45038cd
--- /dev/null
+++ b/src/components/generic/Blocks/ImageBlock.jsx
@@ -0,0 +1,71 @@
+import { useState } from "react";
+import { ImageIcon } from "lucide-react";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import { AssetPickerSheet } from "../AssetPickerSheet";
+
+
+function MediaPlaceholder({ onClick }) {
+ return (
+
+ );
+}
+
+export function ImageBlock({ content, onUpdate }) {
+ const [pickerOpen, setPickerOpen] = useState(false);
+
+ return (
+
+
+
+ {content.url ? (
+
setPickerOpen(true)}
+ >
+

+
+
+ ) : (
+
setPickerOpen(true)} />
+ )}
+
+ {content.url && (
+
+
+ onUpdate({ ...content, alt: e.target.value })}
+ />
+
+ )}
+
+ onUpdate({
+ ...content,
+ asset_id: asset.asset_id,
+ url: asset.file_url,
+ })}
+ />
+
+ );
+}
diff --git a/src/components/generic/Blocks/TextBlock.jsx b/src/components/generic/Blocks/TextBlock.jsx
new file mode 100644
index 0000000..d456cf6
--- /dev/null
+++ b/src/components/generic/Blocks/TextBlock.jsx
@@ -0,0 +1,18 @@
+// components/generic/CMS/blocks/TextBlock.jsx
+
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+
+export function TextBlock({ content, onUpdate }) {
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/generic/Blocks/TextImageBlock.jsx b/src/components/generic/Blocks/TextImageBlock.jsx
new file mode 100644
index 0000000..ff9c312
--- /dev/null
+++ b/src/components/generic/Blocks/TextImageBlock.jsx
@@ -0,0 +1,111 @@
+// components/generic/CMS/blocks/TextImageBlock.jsx
+
+import { useState } from "react";
+import { ImageIcon } from "lucide-react";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { AssetPickerSheet } from "../AssetPickerSheet";
+
+export function TextImageBlock({ content, onUpdate }) {
+ const [pickerOpen, setPickerOpen] = useState(false);
+
+ return (
+
+
+ {/* ── Layout ── */}
+
+
+
+
+
+
+
+ {/* ── Text side ── */}
+
+
+
+
+ {/* ── Image side ── */}
+
+
+
+ {content.url ? (
+
setPickerOpen(true)}
+ >
+

+
+
+ ) : (
+
+ )}
+
+ {content.url && (
+
+
+ onUpdate({ ...content, alt: e.target.value })}
+ />
+
+ )}
+
+
+
+
onUpdate({
+ ...content,
+ asset_id: asset.asset_id,
+ url: asset.file_url,
+ })}
+ />
+
+ );
+}
diff --git a/src/components/generic/Blocks/TextVideoBlock.jsx b/src/components/generic/Blocks/TextVideoBlock.jsx
new file mode 100644
index 0000000..30ff5a0
--- /dev/null
+++ b/src/components/generic/Blocks/TextVideoBlock.jsx
@@ -0,0 +1,108 @@
+import { useState } from "react";
+import { VideoIcon } from "lucide-react";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { AssetPickerSheet } from "../AssetPickerSheet";
+
+export function TextVideoBlock({ content, onUpdate }) {
+ const [pickerOpen, setPickerOpen] = useState(false);
+
+ const thumb = content.thumbnail_url ?? null;
+
+ return (
+
+
+ {/* ── Layout ── */}
+
+
+
+
+
+
+
+ {/* ── Text side ── */}
+
+
+
+
+ {/* ── Video side ── */}
+
+
+
+ {content.url ? (
+
setPickerOpen(true)}
+ >
+ {thumb ? (
+

+ ) : (
+
+
+
+ )}
+
+
+
+ ) : (
+
+ )}
+
+
+
+
onUpdate({
+ ...content,
+ asset_id: asset.asset_id,
+ url: asset.file_url,
+ thumbnail_url: asset.thumbnail_url ?? null,
+ })}
+ />
+
+ );
+}
diff --git a/src/components/generic/Blocks/VideoBlock.jsx b/src/components/generic/Blocks/VideoBlock.jsx
new file mode 100644
index 0000000..770dd0f
--- /dev/null
+++ b/src/components/generic/Blocks/VideoBlock.jsx
@@ -0,0 +1,64 @@
+import { useState } from "react";
+import { VideoIcon } from "lucide-react";
+import { Label } from "@/components/ui/label";
+import { AssetPickerSheet } from "../AssetPickerSheet";
+
+export function VideoBlock({ content, onUpdate }) {
+ const [pickerOpen, setPickerOpen] = useState(false);
+ const thumb = content.thumbnail_url ?? null;
+
+ return (
+
+
+
+ {content.url ? (
+
setPickerOpen(true)}
+ >
+ {thumb ? (
+

+ ) : (
+
+
+
+ )}
+
+ {/* Play icon overlay */}
+
+
+ ) : (
+
+ )}
+
+
onUpdate({
+ ...content,
+ asset_id: asset.asset_id,
+ url: asset.file_url,
+ thumbnail_url: asset.thumbnail_url ?? null,
+ })}
+ />
+
+ );
+}
diff --git a/src/contexts/AdminCoursesContext.jsx b/src/contexts/AdminCoursesContext.jsx
new file mode 100644
index 0000000..ae58b63
--- /dev/null
+++ b/src/contexts/AdminCoursesContext.jsx
@@ -0,0 +1,395 @@
+import { createContext, useCallback, useContext, useState } from "react";
+import api from "@/utils/api.util";
+import { toast } from "sonner";
+
+const CoursesContext = createContext(null);
+
+export function useCourses() {
+ const ctx = useContext(CoursesContext);
+ if (!ctx) throw new Error("useCourses must be used within a CoursesProvider");
+ return ctx;
+}
+
+// ─── Initial States ───────────────────────────────────────────────────────────
+
+const PAGINATION_INIT = {
+ page: 1,
+ limit: 10,
+ totalRecords: 0,
+ totalPages: 0,
+ hasPrevPage: false,
+ hasNextPage: false,
+};
+
+const BASE = "/admin/courses";
+
+export function CoursesProvider({ children }) {
+
+ // ─── State ────────────────────────────────────────────────────────────────
+ const [courses, setCourses] = useState([]);
+ const [course, setCourse] = useState(null);
+ const [units, setUnits] = useState([]);
+ const [unit, setUnit] = useState(null);
+ const [lessons, setLessons] = useState([]);
+ const [lesson, setLesson] = useState(null);
+ const [lessonPage, setLessonPage] = useState(null);
+ const [attributes, setAttributes] = useState([]);
+ const [pagination, setPagination] = useState(PAGINATION_INIT);
+ const [loading, setLoading] = useState(false);
+
+ // ─── Request wrapper ──────────────────────────────────────────────────────
+ const request = useCallback(async (fn) => {
+ setLoading(true);
+ try {
+ return await fn();
+ } catch (err) {
+ const message = err?.response?.data?.message ?? "Something went wrong.";
+ toast.error(message);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ // =========================================================================
+ // ─── COURSES ─────────────────────────────────────────────────────────────
+ // =========================================================================
+
+ // ─── GET /admin/courses ───────────────────────────────────────────────────
+ const fetchCourses = useCallback(
+ (params = {}) =>
+ request(async () => {
+ const { filters, sort, ...rest } = params;
+ const { data } = await api.get(BASE, {
+ params: {
+ ...rest,
+ ...(filters?.length ? { filters: JSON.stringify(filters) } : {}),
+ ...(sort?.length ? { sort: JSON.stringify(sort) } : {}),
+ },
+ });
+ const final_data = data?.data;
+ setCourses(final_data?.data ?? []);
+ setPagination(final_data?.pagination ?? PAGINATION_INIT);
+ if (final_data?.attributes?.length) setAttributes(final_data.attributes);
+ return data;
+ }),
+ [request]
+ );
+
+ // ─── GET /admin/courses/:courseId ─────────────────────────────────────────
+ const fetchCourse = useCallback(
+ (courseId) =>
+ request(async () => {
+ const { data } = await api.get(`${BASE}/${courseId}`);
+ const result = data?.data?.data ?? null;
+ setCourse(result);
+ setUnits(result?.units ?? []);
+ return data;
+ }),
+ [request]
+ );
+
+ // ─── POST /admin/courses ──────────────────────────────────────────────────
+ const createCourse = useCallback(
+ (payload) =>
+ request(async () => {
+ const { data } = await api.post(BASE, payload);
+ const course = data?.data?.data ?? null;
+ if (course) {
+ setCourses((prev) => [course, ...prev]);
+ toast.success("Course created successfully.");
+ }
+ return data;
+ }),
+ [request]
+ );
+
+ // ─── PATCH /admin/courses/:courseId ───────────────────────────────────────
+ const updateCourse = useCallback(
+ (courseId, payload) =>
+ request(async () => {
+ const { data } = await api.patch(`${BASE}/${courseId}`, payload);
+ const course = data?.data?.data ?? null;
+ if (course) {
+ setCourses((prev) => prev.map((c) => (c.course_id === courseId ? course : c)));
+ setCourse(course);
+ toast.success("Course updated successfully.");
+ }
+ return data;
+ }),
+ [request]
+ );
+
+ // ─── DELETE /admin/courses/:courseId ──────────────────────────────────────
+ const deleteCourse = useCallback(
+ (courseId) =>
+ request(async () => {
+ const { data } = await api.delete(`${BASE}/${courseId}`);
+ setCourses((prev) => prev.filter((c) => c.course_id !== courseId));
+ setCourse((prev) => (prev?.course_id === courseId ? null : prev));
+ toast.success("Course deleted.");
+ return data;
+ }),
+ [request]
+ );
+
+ // =========================================================================
+ // ─── UNITS ───────────────────────────────────────────────────────────────
+ // =========================================================================
+
+ // ─── GET /admin/courses/:courseId/units ───────────────────────────────────
+ const fetchUnits = useCallback(
+ (courseId, params = {}) =>
+ request(async () => {
+ const { filters, sort, ...rest } = params;
+ const { data } = await api.get(`${BASE}/${courseId}/units`, {
+ params: {
+ ...rest,
+ ...(filters?.length ? { filters: JSON.stringify(filters) } : {}),
+ ...(sort?.length ? { sort: JSON.stringify(sort) } : {}),
+ },
+ });
+ const final_data = data?.data;
+ setUnits(final_data?.data ?? []);
+ setPagination(final_data?.pagination ?? PAGINATION_INIT);
+ if (final_data?.attributes?.length) setAttributes(final_data.attributes);
+ return data;
+ }),
+ [request]
+ );
+
+ // ─── GET /admin/courses/:courseId/units/:unitId ───────────────────────────
+ const fetchUnit = useCallback(
+ (courseId, unitId) =>
+ request(async () => {
+ const { data } = await api.get(`${BASE}/${courseId}/units/${unitId}`);
+ const result = data?.data?.data ?? null;
+ setUnit(result);
+ setLessons(result?.lessons ?? []);
+ return data;
+ }),
+ [request]
+ );
+
+ // ─── POST /admin/courses/:courseId/units ──────────────────────────────────
+ const createUnit = useCallback(
+ (courseId, payload) =>
+ request(async () => {
+ const { data } = await api.post(`${BASE}/${courseId}/units`, payload);
+ const unit = data?.data?.data ?? null;
+ if (unit) {
+ setUnits((prev) => [...prev, unit]);
+ toast.success("Unit created successfully.");
+ }
+ return data;
+ }),
+ [request]
+ );
+
+ // ─── PATCH /admin/courses/:courseId/units/:unitId ─────────────────────────
+ const updateUnit = useCallback(
+ (courseId, unitId, payload) =>
+ request(async () => {
+ const { data } = await api.patch(`${BASE}/${courseId}/units/${unitId}`, payload);
+ const unit = data?.data?.data ?? null;
+ if (unit) {
+ setUnits((prev) => prev.map((u) => (u.unit_id === unitId ? unit : u)));
+ setUnit(unit);
+ toast.success("Unit updated successfully.");
+ }
+ return data;
+ }),
+ [request]
+ );
+
+ // ─── DELETE /admin/courses/:courseId/units/:unitId ────────────────────────
+ const deleteUnit = useCallback(
+ (courseId, unitId) =>
+ request(async () => {
+ const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}`);
+ setUnits((prev) => prev.filter((u) => u.unit_id !== unitId));
+ setUnit((prev) => (prev?.unit_id === unitId ? null : prev));
+ toast.success("Unit deleted.");
+ return data;
+ }),
+ [request]
+ );
+
+ // =========================================================================
+ // ─── LESSONS ─────────────────────────────────────────────────────────────
+ // =========================================================================
+
+ // ─── GET /admin/courses/:courseId/units/:unitId/lessons ───────────────────
+ const fetchLessons = useCallback(
+ (courseId, unitId, params = {}) =>
+ request(async () => {
+ const { filters, sort, ...rest } = params;
+ const { data } = await api.get(`${BASE}/${courseId}/units/${unitId}/lessons`, {
+ params: {
+ ...rest,
+ ...(filters?.length ? { filters: JSON.stringify(filters) } : {}),
+ ...(sort?.length ? { sort: JSON.stringify(sort) } : {}),
+ },
+ });
+ const final_data = data?.data;
+ setLessons(final_data?.data ?? []);
+ setPagination(final_data?.pagination ?? PAGINATION_INIT);
+ if (final_data?.attributes?.length) setAttributes(final_data.attributes);
+ return data;
+ }),
+ [request]
+ );
+
+ // ─── GET /admin/courses/:courseId/units/:unitId/lessons/:lessonId ─────────
+ const fetchLesson = useCallback(
+ (courseId, unitId, lessonId) =>
+ request(async () => {
+ const { data } = await api.get(
+ `${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}`
+ );
+ const result = data?.data?.data ?? null;
+ setLesson(result);
+ setLessonPage(result?.page ?? null);
+ return data;
+ }),
+ [request]
+ );
+
+ // ─── POST /admin/courses/:courseId/units/:unitId/lessons ──────────────────
+ const createLesson = useCallback(
+ (courseId, unitId, payload) =>
+ request(async () => {
+ const { data } = await api.post(
+ `${BASE}/${courseId}/units/${unitId}/lessons`,
+ payload
+ );
+ const lesson = data?.data?.data ?? null;
+ if (lesson) {
+ setLessons((prev) => [...prev, lesson]);
+ toast.success("Lesson created successfully.");
+ }
+ return data;
+ }),
+ [request]
+ );
+
+ // ─── PATCH /admin/courses/:courseId/units/:unitId/lessons/:lessonId ───────
+ const updateLesson = useCallback(
+ (courseId, unitId, lessonId, payload) =>
+ request(async () => {
+ const { data } = await api.patch(
+ `${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}`,
+ payload
+ );
+ const lesson = data?.data?.data ?? null;
+ if (lesson) {
+ setLessons((prev) => prev.map((l) => (l.lesson_id === lessonId ? lesson : l)));
+ setLesson(lesson);
+ toast.success("Lesson updated successfully.");
+ }
+ return data;
+ }),
+ [request]
+ );
+
+ // ─── DELETE /admin/courses/:courseId/units/:unitId/lessons/:lessonId ──────
+ const deleteLesson = useCallback(
+ (courseId, unitId, lessonId) =>
+ request(async () => {
+ const { data } = await api.delete(
+ `${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}`
+ );
+ setLessons((prev) => prev.filter((l) => l.lesson_id !== lessonId));
+ setLesson((prev) => (prev?.lesson_id === lessonId ? null : prev));
+ toast.success("Lesson deleted.");
+ return data;
+ }),
+ [request]
+ );
+
+ // =========================================================================
+ // ─── LESSON PAGE ─────────────────────────────────────────────────────────
+ // =========================================================================
+
+ // ─── GET .../lessons/:lessonId/page ───────────────────────────────────────
+ const fetchLessonPage = useCallback(
+ (courseId, unitId, lessonId) =>
+ request(async () => {
+ const { data } = await api.get(
+ `${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}/page`
+ );
+ const page = data?.data?.data ?? null;
+ setLessonPage(page);
+ return data;
+ }),
+ [request]
+ );
+
+ // ─── PUT .../lessons/:lessonId/page ───────────────────────────────────────
+ const saveLessonPage = useCallback(
+ (courseId, unitId, lessonId, payload) =>
+ request(async () => {
+ const { data } = await api.put(
+ `${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}/page`,
+ payload
+ );
+ const page = data?.data?.data ?? null;
+ if (page) {
+ setLessonPage(page);
+ toast.success("Lesson page saved.");
+ }
+ return data;
+ }),
+ [request]
+ );
+
+ // ─── Provider ─────────────────────────────────────────────────────────────
+ return (
+
+ {children}
+
+ );
+}
+
+
diff --git a/src/contexts/provider/AdminProvider.jsx b/src/contexts/provider/AdminProvider.jsx
index e6f6200..b5aa87f 100644
--- a/src/contexts/provider/AdminProvider.jsx
+++ b/src/contexts/provider/AdminProvider.jsx
@@ -3,6 +3,7 @@ import { AssetsProvider } from "../AdminAssetsContext";
import { AdminDashboardProvider } from "../AdminDashboardContext"
import { UserProvider } from "../AdminUserContext";
import { UserGroupProvider } from "../AdminUserGroupContext";
+import { CoursesProvider } from "../AdminCoursesContext";
export const AdminProvider = ({ children }) => {
return (
@@ -10,10 +11,12 @@ export const AdminProvider = ({ children }) => {
- {children}
+
+ {children}
+
);
-};
\ No newline at end of file
+};
diff --git a/src/data/adminDashboard.data.jsx b/src/data/adminDashboard.data.jsx
index 640ff01..bf51735 100644
--- a/src/data/adminDashboard.data.jsx
+++ b/src/data/adminDashboard.data.jsx
@@ -1,5 +1,3 @@
-// modules/admin/data/dashboard.data.jsx
-//
// Centralizes all icon maps, link maps, and chart link maps for the
// admin dashboard. To add a new section (e.g. Tasks), just add a new
// export block here and import it in UsersDashboard.jsx.
diff --git a/src/data/adminTiles.data.js b/src/data/adminTiles.data.js
index 8afaaef..25b373b 100644
--- a/src/data/adminTiles.data.js
+++ b/src/data/adminTiles.data.js
@@ -1,6 +1,4 @@
-// data/adminTiles.data.js
-
-import { Users, GitFork, FolderOpen } from "lucide-react";
+import { Users, GitFork, FolderOpen, BookText, ListCheck } from "lucide-react";
export const ADMIN_SECTIONS = [
{
@@ -22,6 +20,16 @@ export const ADMIN_SECTIONS = [
{ key: "assets", label: "Assets", icon: FolderOpen, link: "/admin/assets" },
],
},
+ {
+ id: "section-courses",
+ tab: "Content Management",
+ title: "Content Management",
+ description: "Manage tasks and courses",
+ tiles: [
+ { key: "assets", label: "Courses", icon: BookText, link: "/admin/courses" },
+ { key: "assets", label: "Tasks", icon: ListCheck, link: "" },
+ ],
+ },
{
id: "section-site",
tab: "Site Content",
diff --git a/src/modules/admin/components/courses/CourseTable.jsx b/src/modules/admin/components/courses/CourseTable.jsx
new file mode 100644
index 0000000..ab38a83
--- /dev/null
+++ b/src/modules/admin/components/courses/CourseTable.jsx
@@ -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 (
+ <>
+ []}
+ onRefsReady={handleRefsReady}
+ renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
+
+ )}
+ columnPinning={columnPinning}
+ toolbarActions={toolbarActions}
+ selectionActions={selectionActions}
+ recordLabel="course"
+ emptyMessage="No courses found."
+ />
+
+ !v && setArchiveTarget(null)}
+ entity={archiveTarget}
+ entityLabel="Course"
+ getName={(c) => c?.title}
+ onArchive={(c) => deleteCourse(c?.course_id)}
+ loading={loading}
+ onSuccess={handleArchiveSuccess}
+ />
+ >
+ );
+}
diff --git a/src/modules/admin/components/courses/LessonsTable.jsx b/src/modules/admin/components/courses/LessonsTable.jsx
new file mode 100644
index 0000000..de5004f
--- /dev/null
+++ b/src/modules/admin/components/courses/LessonsTable.jsx
@@ -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 (
+ <>
+ []}
+ onRefsReady={handleRefsReady}
+ renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
+
+ )}
+ columnPinning={columnPinning}
+ toolbarActions={toolbarActions}
+ recordLabel="lesson"
+ emptyMessage="No lessons found."
+ />
+
+ !v && setArchiveTarget(null)}
+ entity={archiveTarget}
+ entityLabel="Lesson"
+ getName={(l) => l?.title}
+ onArchive={(l) => deleteLesson(courseId, unitId, l?.lesson_id)}
+ loading={loading}
+ onSuccess={handleArchiveSuccess}
+ />
+ >
+ );
+}
diff --git a/src/modules/admin/components/courses/UnitsTable.jsx b/src/modules/admin/components/courses/UnitsTable.jsx
new file mode 100644
index 0000000..4013619
--- /dev/null
+++ b/src/modules/admin/components/courses/UnitsTable.jsx
@@ -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 (
+ <>
+ []}
+ onRefsReady={handleRefsReady}
+ renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
+
+ )}
+ columnPinning={columnPinning}
+ toolbarActions={toolbarActions}
+ recordLabel="unit"
+ emptyMessage="No units found."
+ />
+
+ !v && setArchiveTarget(null)}
+ entity={archiveTarget}
+ entityLabel="Unit"
+ getName={(u) => u?.title}
+ onArchive={(u) => deleteUnit(courseId, u?.unit_id)}
+ loading={loading}
+ onSuccess={handleArchiveSuccess}
+ />
+ >
+ );
+}
diff --git a/src/modules/admin/config/courses/columns.config.jsx b/src/modules/admin/config/courses/columns.config.jsx
new file mode 100644
index 0000000..db75d6a
--- /dev/null
+++ b/src/modules/admin/config/courses/columns.config.jsx
@@ -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 }) => (
+ {row.original.title}
+ ),
+ },
+ {
+ accessorKey: "description",
+ header: "Description",
+ cell: ({ row }) => (
+
+ {row.original.description ?? "—"}
+
+ ),
+ },
+ {
+ accessorKey: "order",
+ header: "Order",
+ cell: ({ row }) => (
+
+ {row.original.order}
+
+ ),
+ },
+ ];
+
+ return rowActions ? [...base, rowActions] : base;
+}
diff --git a/src/modules/admin/config/courses/lessons/columns.config.jsx b/src/modules/admin/config/courses/lessons/columns.config.jsx
new file mode 100644
index 0000000..9096867
--- /dev/null
+++ b/src/modules/admin/config/courses/lessons/columns.config.jsx
@@ -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 }) => (
+ {row.original.title}
+ ),
+ },
+ {
+ accessorKey: "description",
+ header: "Description",
+ cell: ({ row }) => (
+
+ {row.original.description ?? "—"}
+
+ ),
+ },
+ {
+ accessorKey: "order",
+ header: "Order",
+ cell: ({ row }) => (
+
+ {row.original.order}
+
+ ),
+ },
+ {
+ accessorKey: "page",
+ header: "Content",
+ cell: ({ row }) => {
+ const blocks = row.original.page?.blocks ?? [];
+ return blocks.length ? (
+
+ {blocks.length} block{blocks.length !== 1 ? "s" : ""}
+
+ ) : (
+ No content
+ );
+ },
+ },
+ ];
+
+ return rowActions ? [...base, rowActions] : base;
+}
diff --git a/src/modules/admin/config/courses/lessons/rowActions.config.jsx b/src/modules/admin/config/courses/lessons/rowActions.config.jsx
new file mode 100644
index 0000000..48ce7b4
--- /dev/null
+++ b/src/modules/admin/config/courses/lessons/rowActions.config.jsx
@@ -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 }) => (
+
+
+
+
+
+ ),
+ };
+}
diff --git a/src/modules/admin/config/courses/lessons/toolbar.config.jsx b/src/modules/admin/config/courses/lessons/toolbar.config.jsx
new file mode 100644
index 0000000..88db67e
--- /dev/null
+++ b/src/modules/admin/config/courses/lessons/toolbar.config.jsx
@@ -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: ,
+ variant: "outline",
+ onClick: () => fetchLessons({
+ page: 1,
+ limit: pagination?.limit ?? 10,
+ filters: getFilters(),
+ sort: getSort(),
+ }),
+ },
+ {
+ key: "export",
+ type: "button",
+ label: "Export",
+ icon: ,
+ variant: "outline",
+ onClick: () => exportTableToExcel({
+ ...exportConfig,
+ tableInstance: getTableInstance(),
+ }),
+ },
+ {
+ key: "create",
+ type: "button",
+ label: "New Lesson",
+ icon: ,
+ variant: "default",
+ onClick: () => navigate(
+ `/admin/courses/${courseId}/units/${unitId}/lessons/create`
+ ),
+ },
+ ];
+}
diff --git a/src/modules/admin/config/courses/rowActions.config.jsx b/src/modules/admin/config/courses/rowActions.config.jsx
new file mode 100644
index 0000000..ddbb004
--- /dev/null
+++ b/src/modules/admin/config/courses/rowActions.config.jsx
@@ -0,0 +1,33 @@
+import { Eye, Pencil, Trash2 } from "lucide-react";
+
+export function buildRowActions({ onView, onEdit, onArchive }) {
+ return {
+ id: "actions",
+ header: "",
+ cell: ({ row }) => (
+
+
+
+
+
+ ),
+ };
+}
diff --git a/src/modules/admin/config/courses/selection.config.jsx b/src/modules/admin/config/courses/selection.config.jsx
new file mode 100644
index 0000000..8be6485
--- /dev/null
+++ b/src/modules/admin/config/courses/selection.config.jsx
@@ -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: ,
+ onClick: (rows, table) =>
+ exportTableToExcel({
+ ...exportConfig,
+ selectedRows: rows,
+ tableInstance: table ?? getTableInstance(),
+ }),
+ },
+ ];
+}
diff --git a/src/modules/admin/config/courses/toolbar.config.jsx b/src/modules/admin/config/courses/toolbar.config.jsx
new file mode 100644
index 0000000..96a88f3
--- /dev/null
+++ b/src/modules/admin/config/courses/toolbar.config.jsx
@@ -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: ,
+ variant: "outline",
+ onClick: () => fetchCourses({
+ page: 1,
+ limit: pagination?.limit ?? 10,
+ filters: getFilters(),
+ sort: getSort(),
+ }),
+ },
+ {
+ key: "export",
+ type: "button",
+ label: "Export",
+ icon: ,
+ variant: "outline",
+ onClick: () => exportTableToExcel({
+ ...exportConfig,
+ tableInstance: getTableInstance(),
+ }),
+ },
+ {
+ key: "create",
+ type: "button",
+ label: "New Course",
+ icon: ,
+ variant: "default",
+ onClick: () => navigate("/admin/courses/create"),
+ },
+ ];
+}
diff --git a/src/modules/admin/config/courses/units/columns.config.jsx b/src/modules/admin/config/courses/units/columns.config.jsx
new file mode 100644
index 0000000..c5af0ee
--- /dev/null
+++ b/src/modules/admin/config/courses/units/columns.config.jsx
@@ -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 }) => (
+ {row.original.title}
+ ),
+ },
+ {
+ accessorKey: "description",
+ header: "Description",
+ cell: ({ row }) => (
+
+ {row.original.description ?? "—"}
+
+ ),
+ },
+ {
+ accessorKey: "order",
+ header: "Order",
+ cell: ({ row }) => (
+
+ {row.original.order}
+
+ ),
+ },
+ ];
+
+ return rowActions ? [...base, rowActions] : base;
+}
diff --git a/src/modules/admin/config/courses/units/rowActions.config.jsx b/src/modules/admin/config/courses/units/rowActions.config.jsx
new file mode 100644
index 0000000..ddbb004
--- /dev/null
+++ b/src/modules/admin/config/courses/units/rowActions.config.jsx
@@ -0,0 +1,33 @@
+import { Eye, Pencil, Trash2 } from "lucide-react";
+
+export function buildRowActions({ onView, onEdit, onArchive }) {
+ return {
+ id: "actions",
+ header: "",
+ cell: ({ row }) => (
+
+
+
+
+
+ ),
+ };
+}
diff --git a/src/modules/admin/config/courses/units/toolbar.config.jsx b/src/modules/admin/config/courses/units/toolbar.config.jsx
new file mode 100644
index 0000000..efe3b54
--- /dev/null
+++ b/src/modules/admin/config/courses/units/toolbar.config.jsx
@@ -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: ,
+ variant: "outline",
+ onClick: () => fetchUnits({
+ page: 1,
+ limit: pagination?.limit ?? 10,
+ filters: getFilters(),
+ sort: getSort(),
+ }),
+ },
+ {
+ key: "export",
+ type: "button",
+ label: "Export",
+ icon: ,
+ variant: "outline",
+ onClick: () => exportTableToExcel({
+ ...exportConfig,
+ tableInstance: getTableInstance(),
+ }),
+ },
+ {
+ key: "create",
+ type: "button",
+ label: "New Unit",
+ icon: ,
+ variant: "default",
+ onClick: () => navigate(`/admin/courses/${courseId}/units/create`),
+ },
+ ];
+}
diff --git a/src/modules/admin/pages/courses/CourseDetail.jsx b/src/modules/admin/pages/courses/CourseDetail.jsx
new file mode 100644
index 0000000..3de1d7d
--- /dev/null
+++ b/src/modules/admin/pages/courses/CourseDetail.jsx
@@ -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: , to: "/admin" },
+ { label: "Courses", to: "/admin/courses" },
+ { label: course?.title ?? "..." },
+ ];
+
+ // Replace the loading check
+ if (initializing) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {/* ── Course header ── */}
+
+
+
+
+
{course?.title}
+ {course?.description && (
+
{course.description}
+ )}
+
+
+
+
+
+ {/* ── Units ── */}
+
+
+
+
+
+ );
+}
diff --git a/src/modules/admin/pages/courses/CourseList.jsx b/src/modules/admin/pages/courses/CourseList.jsx
new file mode 100644
index 0000000..3c9adba
--- /dev/null
+++ b/src/modules/admin/pages/courses/CourseList.jsx
@@ -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: , to: "/admin" },
+ { label: "Courses" },
+ ];
+
+ return (
+
+ );
+}
diff --git a/src/modules/admin/pages/courses/CreateCourse.jsx b/src/modules/admin/pages/courses/CreateCourse.jsx
new file mode 100644
index 0000000..1094c5e
--- /dev/null
+++ b/src/modules/admin/pages/courses/CreateCourse.jsx
@@ -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 {message}
;
+}
+
+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: , 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 (
+
+
+
+
+
+
+
+
+
Create Course
+
Add a new course.
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/modules/admin/pages/courses/CreateLesson.jsx b/src/modules/admin/pages/courses/CreateLesson.jsx
new file mode 100644
index 0000000..ee8bc4c
--- /dev/null
+++ b/src/modules/admin/pages/courses/CreateLesson.jsx
@@ -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 {message}
;
+}
+
+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: , 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 (
+
+
+
+
+
+
+
+
+
Create Lesson
+
Add a new lesson to this unit.
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/modules/admin/pages/courses/CreateUnit.jsx b/src/modules/admin/pages/courses/CreateUnit.jsx
new file mode 100644
index 0000000..086f34f
--- /dev/null
+++ b/src/modules/admin/pages/courses/CreateUnit.jsx
@@ -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 {message}
;
+}
+
+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: , 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 (
+
+
+
+
+
+
+
+
+
Create Unit
+
Add a new unit to this course.
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/modules/admin/pages/courses/EditCourse.jsx b/src/modules/admin/pages/courses/EditCourse.jsx
new file mode 100644
index 0000000..da50267
--- /dev/null
+++ b/src/modules/admin/pages/courses/EditCourse.jsx
@@ -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 {message}
;
+}
+
+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: , 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 (
+
+
+
+
+
+
+
+
+
Edit Course
+
Update course details.
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/modules/admin/pages/courses/EditLesson.jsx b/src/modules/admin/pages/courses/EditLesson.jsx
new file mode 100644
index 0000000..2fafad0
--- /dev/null
+++ b/src/modules/admin/pages/courses/EditLesson.jsx
@@ -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 {message}
;
+}
+
+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: , 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 (
+
+
+
+
+
+
+
+
+
Edit Lesson
+
Update lesson details.
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/modules/admin/pages/courses/EditUnit.jsx b/src/modules/admin/pages/courses/EditUnit.jsx
new file mode 100644
index 0000000..ebfbd05
--- /dev/null
+++ b/src/modules/admin/pages/courses/EditUnit.jsx
@@ -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 {message}
;
+}
+
+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: , 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 (
+
+
+
+
+
+
+
+
+
Edit Unit
+
Update unit details.
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/modules/admin/pages/courses/LessonDetail.jsx b/src/modules/admin/pages/courses/LessonDetail.jsx
new file mode 100644
index 0000000..ca32b8f
--- /dev/null
+++ b/src/modules/admin/pages/courses/LessonDetail.jsx
@@ -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: , 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 (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {/* ── Header ── */}
+
+
+
+
+
{lesson?.title}
+ {lesson?.description && (
+
{lesson.description}
+ )}
+
+
+
+
+
+ {/* ── Page builder card ── */}
+
+
+
+
+
Lesson Content
+
+ {lessonPage?.blocks?.length
+ ? `${lessonPage.blocks.length} block${lessonPage.blocks.length !== 1 ? "s" : ""}`
+ : "No content yet"}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/modules/admin/pages/courses/LessonPageBuilder.jsx b/src/modules/admin/pages/courses/LessonPageBuilder.jsx
new file mode 100644
index 0000000..fd60dda
--- /dev/null
+++ b/src/modules/admin/pages/courses/LessonPageBuilder.jsx
@@ -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: , 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 (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {/* ── Header ── */}
+
+
+
+
Page Builder
+
+ {lesson?.title}
+
+
+
+
+ {/* ── Blocks ── */}
+
+
+
+ Content Blocks
+ {blocks.length > 0 && (
+
+ {blocks.length} block{blocks.length !== 1 ? "s" : ""}
+
+ )}
+
+
+
+
+
+
+
+
+ {/* ── Actions ── */}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/modules/admin/pages/courses/UnitDetail.jsx b/src/modules/admin/pages/courses/UnitDetail.jsx
new file mode 100644
index 0000000..717e3c5
--- /dev/null
+++ b/src/modules/admin/pages/courses/UnitDetail.jsx
@@ -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: , 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 (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {/* ── Unit header ── */}
+
+
+
+
+
{unit?.title}
+ {unit?.description && (
+
{unit.description}
+ )}
+
+
+
+
+
+ {/* ── Lessons ── */}
+
+
+
+
+
+ );
+}
diff --git a/src/modules/admin/routes/AdminRoutes.jsx b/src/modules/admin/routes/AdminRoutes.jsx
index e185ac5..a8ad595 100644
--- a/src/modules/admin/routes/AdminRoutes.jsx
+++ b/src/modules/admin/routes/AdminRoutes.jsx
@@ -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: ,
@@ -74,9 +85,29 @@ export const AdminRoutes = {
{ path: 'view/document/:assetId', element: },
{ path: 'edit/:assetId', element: },
],
- }
+ },
+
+ // Courses
+ {
+ path: 'courses',
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: 'create', element: },
+ { path: ':courseId', element: },
+ { path: ':courseId/edit', element: },
+ { path: ':courseId/units/create', element: },
+ { path: ':courseId/units/:unitId', element: },
+ { path: ':courseId/units/:unitId/edit', element: },
+ { path: ':courseId/units/:unitId/lessons/create', element: },
+ { path: ':courseId/units/:unitId/lessons/:lessonId', element: },
+ { path: ':courseId/units/:unitId/lessons/:lessonId/edit', element: },
+ { path: ':courseId/units/:unitId/lessons/:lessonId/page', element: },
+ ]
+ },
+
// Add here
]
},
],
-}
\ No newline at end of file
+}