wip: course layout and func to context

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-05-16 11:05:04 +08:00
parent dcf5f43fd5
commit 2b1955608d
38 changed files with 3086 additions and 9 deletions
@@ -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>
);
}