mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
Adjusted
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
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),
|
||||
objectives: z.array(
|
||||
z.object({ value: z.string().min(1, "Objective cannot be empty.") })
|
||||
).optional(),
|
||||
});
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
export default function AddLesson() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId } = useParams();
|
||||
const { createLesson, fetchUnit, course, unit, loading } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { register, handleSubmit, control, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", order: 0, objectives: [] },
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({ control, name: "objectives" });
|
||||
|
||||
useEffect(() => {
|
||||
fetchUnit(courseId, unitId);
|
||||
}, [courseId, unitId]);
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const payload = {
|
||||
...data,
|
||||
objectives: data.objectives?.map((o) => o.value) ?? [],
|
||||
createdBy: user?.user_id,
|
||||
};
|
||||
const result = await createLesson(courseId, unitId, payload);
|
||||
if (!result) return;
|
||||
navigate(`/admin/courses/${courseId}/units/${unitId}/lessons`);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
|
||||
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Create Lesson</h1>
|
||||
<p className="text-sm text-muted-foreground">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>
|
||||
|
||||
{/* Objectives */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Objectives</p>
|
||||
<p className="text-xs text-muted-foreground">What learners will achieve from this lesson.</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ value: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No objectives yet. Click Add to get started.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
placeholder={`Objective ${index + 1}`}
|
||||
{...register(`objectives.${index}.value`)}
|
||||
/>
|
||||
<FieldError message={errors.objectives?.[index]?.value?.message} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
className="text-muted-foreground hover:text-destructive mt-0.5"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</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,173 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, House, Plus, Trash2 } 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),
|
||||
objectives: z.array(
|
||||
z.object({ value: z.string().min(1, "Objective cannot be empty.") })
|
||||
).optional(),
|
||||
});
|
||||
|
||||
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 [lessonTitle, setLessonTitle] = useState("");
|
||||
|
||||
const { register, handleSubmit, reset, control, formState: { errors, isDirty } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", order: 0, objectives: [] },
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({ control, name: "objectives" });
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await fetchLesson(courseId, unitId, lessonId);
|
||||
const lesson = res?.data?.data ?? null;
|
||||
if (!lesson) return;
|
||||
setLessonTitle(lesson.title ?? "");
|
||||
reset({
|
||||
title: lesson.title ?? "",
|
||||
description: lesson.description ?? "",
|
||||
order: lesson.order ?? 0,
|
||||
objectives: lesson.objectives?.map((v) => ({ value: v.text })) ?? [],
|
||||
});
|
||||
})();
|
||||
}, [courseId, unitId, lessonId]);
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
if (!isDirty) return navigate(-1);
|
||||
const result = await updateLesson(courseId, unitId, lessonId, {
|
||||
...data,
|
||||
objectives: data.objectives?.map((o, i) => ({
|
||||
objective_id: o.objective_id ?? null,
|
||||
text: o.value,
|
||||
order_index: i,
|
||||
})) ?? [],
|
||||
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-center px-4 py-10">
|
||||
|
||||
<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>
|
||||
|
||||
{/* Objectives */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Objectives</p>
|
||||
<p className="text-xs text-muted-foreground">What learners will achieve from this lesson.</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ value: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No objectives yet. Click Add to get started.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
placeholder={`Objective ${index + 1}`}
|
||||
{...register(`objectives.${index}.value`)}
|
||||
/>
|
||||
<FieldError message={errors.objectives?.[index]?.value?.message} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
className="text-muted-foreground hover:text-destructive mt-0.5"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</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,246 @@
|
||||
// pages/admin/LessonPageBuilder.jsx
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Save, Pencil, Monitor, Eye, EyeOff, X } from "lucide-react";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
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";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PreviewContent, PreviewChrome } from "../../../components/courses/LessonsPreview";
|
||||
|
||||
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);
|
||||
const [previewVisible, setPreviewVisible] = useState(true);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
|
||||
const headerRef = useRef(null);
|
||||
const blocksSeeded = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await fetchLesson(courseId, unitId, lessonId);
|
||||
setInitializing(false);
|
||||
})();
|
||||
}, [courseId, unitId, lessonId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lessonPage?.blocks?.length && !blocksSeeded.current) {
|
||||
setBlocks(lessonPage.blocks);
|
||||
blocksSeeded.current = true;
|
||||
}
|
||||
}, [lessonPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!headerRef.current) return;
|
||||
const update = () => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--builder-h",
|
||||
`${headerRef.current.offsetHeight}px`
|
||||
);
|
||||
};
|
||||
update();
|
||||
window.addEventListener("resize", update);
|
||||
return () => window.removeEventListener("resize", update);
|
||||
}, []);
|
||||
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const result = await saveLessonPage(courseId, unitId, lessonId, {
|
||||
blocks,
|
||||
updatedBy: user?.user_id,
|
||||
});
|
||||
if (!result) return;
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
const editorStyle = previewVisible
|
||||
? { top: "calc(var(--navbar-h) + var(--builder-h, 0px))" }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-muted/60">
|
||||
|
||||
{/* ── Sticky builder header ── */}
|
||||
<div
|
||||
ref={headerRef}
|
||||
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
|
||||
style={{ top: "var(--navbar-h)" }}
|
||||
>
|
||||
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-semibold leading-tight">Page Builder</h1>
|
||||
<p className="text-sm text-muted-foreground truncate">{lesson?.title}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setPreviewVisible((v) => !v)}
|
||||
className="hidden lg:inline-flex gap-2"
|
||||
>
|
||||
{previewVisible
|
||||
? <><EyeOff className="h-4 w-4" /> Hide Preview</>
|
||||
: <><Eye className="h-4 w-4" /> Show Preview</>
|
||||
}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
className="lg:hidden gap-2"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* ── Split pane ── */}
|
||||
<div className="flex flex-1 lg:container lg:mx-auto lg:px-6 px-0 w-full items-start">
|
||||
|
||||
{/* LEFT — Editor */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-full border-r",
|
||||
previewVisible ? "lg:w-[40%] lg:shrink-0 lg:sticky" : "lg:w-full"
|
||||
)}
|
||||
style={editorStyle}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3 p-4 pb-10",
|
||||
previewVisible && "lg:overflow-y-auto"
|
||||
)}
|
||||
ref={(el) => {
|
||||
if (!el) return;
|
||||
const applyHeight = () => {
|
||||
if (window.innerWidth >= 1024 && previewVisible) {
|
||||
el.style.height = `calc(100vh - var(--navbar-h) - var(--builder-h, 0px))`;
|
||||
} else {
|
||||
el.style.height = "auto";
|
||||
}
|
||||
};
|
||||
applyHeight();
|
||||
window.addEventListener("resize", applyHeight);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground pt-1">
|
||||
<Pencil className="h-4 w-4" />
|
||||
Editor
|
||||
{blocks.length > 0 && (
|
||||
<span className="text-xs font-normal">
|
||||
· {blocks.length} block{blocks.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{initializing ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<BlockList blocks={blocks} onUpdate={updateBlock} onMove={moveBlock} onDelete={deleteBlock} />
|
||||
<AddBlockMenu onAdd={addBlock} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT — Live Preview (desktop only) */}
|
||||
{previewVisible && (
|
||||
<div className="hidden lg:flex flex-1 flex-col gap-3 p-4 pb-10">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground pt-1">
|
||||
<Monitor className="h-4 w-4" />
|
||||
Live Preview
|
||||
</div>
|
||||
<PreviewChrome title={lesson?.title}>
|
||||
<div className="p-6 space-y-5 min-h-[300px]">
|
||||
<PreviewContent
|
||||
lesson={lesson}
|
||||
blocks={blocks}
|
||||
empty="Your content will appear here as you build."
|
||||
/>
|
||||
</div>
|
||||
</PreviewChrome>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Mobile preview bottom sheet ── */}
|
||||
{previewOpen && (
|
||||
<div className="lg:hidden fixed inset-0 z-40 flex flex-col">
|
||||
<div className="flex-1 bg-black/50" onClick={() => setPreviewOpen(false)} />
|
||||
<div className="bg-background rounded-t-2xl border-t shadow-xl flex flex-col max-h-[85dvh]">
|
||||
<div className="flex items-center justify-between px-4 pt-4 pb-3 border-b shrink-0">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Monitor className="h-4 w-4 text-muted-foreground" />
|
||||
Live Preview
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => setPreviewOpen(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 p-4">
|
||||
<PreviewChrome title={lesson?.title}>
|
||||
<div className="p-4 space-y-4 min-h-[200px]">
|
||||
<PreviewContent
|
||||
lesson={lesson}
|
||||
blocks={blocks}
|
||||
empty="No content yet."
|
||||
/>
|
||||
</div>
|
||||
</PreviewChrome>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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 LessonsList() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId } = useParams();
|
||||
const { fetchCourse, fetchUnit, course, unit, loading } = useCourses();
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await fetchCourse(courseId)
|
||||
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, to: `/admin/courses/${courseId}/units` },
|
||||
{ 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">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{unit?.title}</h1>
|
||||
{unit?.description && (
|
||||
<p className="text-sm text-muted-foreground">{unit.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Lessons ── */}
|
||||
<LessonsTable courseId={courseId} unitId={unitId} />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { House, Pencil, ArrowLeft, Clock, ListChecks, FileText } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
function InfoField({ label, value }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">{label}</p>
|
||||
<p className="text-sm font-medium">{value ?? "—"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ViewLesson() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId, lessonId } = useParams();
|
||||
const { fetchLesson, course, unit } = useCourses();
|
||||
const [lesson, setLesson] = useState(null);
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await fetchLesson(courseId, unitId, lessonId);
|
||||
setLesson(res?.data?.data ?? null);
|
||||
setInitializing(false);
|
||||
})();
|
||||
}, [courseId, unitId, lessonId]);
|
||||
|
||||
if (initializing) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatDate = (iso) =>
|
||||
iso ? new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : "—";
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
|
||||
<div className="w-full max-w-2xl 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>
|
||||
<p className="text-sm text-muted-foreground">Lesson details.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/edit`)}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div className="bg-white rounded-lg border p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Order</p>
|
||||
<p className="font-semibold text-sm">#{lesson?.order_index ?? 0}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" /> Duration
|
||||
</p>
|
||||
<p className="font-semibold text-sm">{lesson?.duration_formatted ?? "0 mins"}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Created</p>
|
||||
<p className="font-semibold text-sm">{formatDate(lesson?.createdAt)}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Last Updated</p>
|
||||
<p className="font-semibold text-sm">{formatDate(lesson?.updatedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
<InfoField label="Title" value={lesson?.title} />
|
||||
<InfoField label="Description" value={lesson?.description || "No description provided."} />
|
||||
<InfoField label="Unit" value={lesson?.unit?.title} />
|
||||
</div>
|
||||
|
||||
{/* Objectives */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListChecks className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">Objectives</p>
|
||||
<Badge variant="secondary" className="ml-auto">{lesson?.objectives?.length ?? 0}</Badge>
|
||||
</div>
|
||||
|
||||
{lesson?.objectives?.length === 0 || !lesson?.objectives ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No objectives defined.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{lesson.objectives.map((obj, i) => (
|
||||
<li key={obj.objective_id} className="flex items-start gap-3 text-sm">
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-medium">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span>{obj.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { PreviewContent, PreviewChrome } from "../../../components/courses/LessonsPreview";
|
||||
|
||||
export default function ViewLessonPage() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId, lessonId } = useParams();
|
||||
const { fetchLesson, lesson, lessonPage } = useCourses();
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await fetchLesson(courseId, unitId, lessonId);
|
||||
setInitializing(false);
|
||||
})();
|
||||
}, [courseId, unitId, lessonId]);
|
||||
|
||||
const blocks = lessonPage?.blocks ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-muted/60">
|
||||
|
||||
{/* Sticky header */}
|
||||
<div
|
||||
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
|
||||
style={{ top: "var(--navbar-h)" }}
|
||||
>
|
||||
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-semibold leading-tight">Page Preview</h1>
|
||||
<p className="text-sm text-muted-foreground truncate">{lesson?.title}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page-builder`)}
|
||||
>
|
||||
Edit Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<PreviewChrome title={lesson?.title}>
|
||||
<div className="p-8 space-y-6 min-h-[400px]">
|
||||
{initializing ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<PreviewContent
|
||||
lesson={lesson}
|
||||
blocks={blocks}
|
||||
empty="No content blocks yet."
|
||||
/>
|
||||
{blocks.length === 0 && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page-builder`)}
|
||||
>
|
||||
Go to Page Builder
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PreviewChrome>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user