mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
254 lines
10 KiB
React
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>
|
|
);
|
|
} |