Files
starr-philproperties/src/modules/admin/pages/courses/lessons/EditLesson.jsx
T
2026-08-09 17:33:21 +08:00

254 lines
10 KiB
React

import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useForm, useFieldArray, useWatch } 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 { PageMeta } from "@/contexts/MetadataContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import api from "@/utils/api.util";
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";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import CompletionRequirementBuilder from "@/modules/admin/components/courses/CompletionRequirementBuilder";
const schema = z.object({
title: z.string().min(1, "Title is required."),
description: z.string().optional(),
order: z.coerce.number().min(0).default(0),
subscription: z.string().optional(),
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, fetchCourse, fetchUnit, course, unit, loading, fetchLessonRequirements, syncLessonRequirements } = useCourses();
const { user } = useAuth();
const [lessonTitle, setLessonTitle] = useState("");
const [tierCategories, setTierCategories] = useState([]);
useEffect(() => {
api.get("/admin/tiers/categories")
.then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active)))
.catch(() => {});
}, []);
const { register, handleSubmit, reset, control, setValue, formState: { errors, isDirty } } = useForm({
resolver: zodResolver(schema),
defaultValues: { title: "", description: "", order: 0, subscription: "free", objectives: [] },
});
const watchedSubscr = useWatch({ control, name: "subscription" });
const defaultTierSlug = tierCategories.find((c) => c.is_default)?.slug || "free";
const { fields, append, remove, insert, update } = useFieldArray({ control, name: "objectives" });
const handleObjectivePaste = (e, index) => {
const text = e.clipboardData.getData("text");
const lines = text.split(/\r\n|\r|\n/).map((l) => l.trim()).filter(Boolean);
if (lines.length <= 1) return;
e.preventDefault();
update(index, { value: lines[0] });
lines.slice(1).forEach((line, i) => {
insert(index + 1 + i, { value: line });
});
};
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
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,
subscription: lesson.subscription || defaultTierSlug,
objectives: lesson.objectives?.map((v) => ({ value: v.text })) ?? [],
});
})();
fetchUnit(courseId, unitId);
if (!course || String(course.course_id) !== String(courseId)) {
fetchCourse(courseId);
}
}, [courseId, unitId, lessonId]);
const onSubmit = async (data) => {
if (!isDirty) { bypassOnce(); return navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/view`); }
const result = await updateLesson(courseId, unitId, lessonId, {
...data,
subscription: data.subscription || defaultTierSlug,
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;
bypassOnce();
navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/view`);
};
return (
<section className="bg-muted h-full">
<PageMeta title={lessonTitle ? `Edit: ${lessonTitle} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
<div className="w-full max-w-2xl">
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/view`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Edit Lesson</h1>
<p className="text-sm text-muted-foreground">
Lessons live in the shared Library — changes here apply everywhere this
lesson is used, not just{unit ? ` "${unit.title}"` : " this unit"}
{course ? ` (${course.title})` : ""}.
</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 className="space-y-1.5">
<Label>Subscription</Label>
<Select
value={watchedSubscr || defaultTierSlug}
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="No subscription gate" />
</SelectTrigger>
<SelectContent>
{tierCategories.map((c) => (
<SelectItem key={c.slug} value={c.slug}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Optional. Gates this lesson directly, independent of the unit it's attached to.
</p>
</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`)}
onPaste={(e) => handleObjectivePaste(e, index)}
/>
<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(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/view`)} 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 className="rounded-lg border bg-card p-6 space-y-4 mt-6">
<div>
<h2 className="text-sm font-semibold">Completion Requirements</h2>
<p className="text-xs text-muted-foreground">What a learner must do for this lesson to count as complete.</p>
</div>
<CompletionRequirementBuilder
entityType="lesson"
fetchFn={fetchLessonRequirements}
syncFn={syncLessonRequirements}
args={[courseId, unitId, lessonId]}
/>
</div>
</div>
</div>
{unsavedChangesDialog}
</section>
);
}