Yimaru-Admin/src/pages/content-management/ModuleDetailPage.tsx
2026-04-25 02:48:52 -07:00

677 lines
24 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import {
ArrowLeft,
Video,
Calendar,
Mic,
Layers,
Edit2,
Trash2,
X,
} from "lucide-react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import {
deleteTopLevelModuleLesson,
getModuleLessons,
getTopLevelCourseModules,
updateTopLevelModuleLesson,
} from "../../api/courses.api";
import type { TopLevelModuleLessonItem } from "../../types/course.types";
import { Button } from "../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
import { cn } from "../../lib/utils";
import { LessonMediaUploadField } from "./components/LessonMediaUploadField";
import { VideoCard } from "./components/VideoCard";
const LESSON_THUMB_GRADIENTS = [
"from-[#CBD5E1] to-[#94A3B8]",
"from-[#DBEAFE] to-[#93C5FD]",
"from-[#FEF3C7] to-[#FCD34D]",
"from-[#FCE7F3] to-[#F9A8D4]",
] as const;
const MOCK_PRACTICES = [
{
id: "p1",
title: "Describe a Photo",
level: "IELTS",
variations: 12,
status: "Draft",
},
{
id: "p2",
title: "Describe a Photo",
level: "IELTS",
variations: 12,
status: "Draft",
},
{
id: "p3",
title: "Describe a Photo",
level: "IELTS",
variations: 12,
status: "Draft",
},
{
id: "p4",
title: "Describe a Photo",
level: "IELTS",
variations: 12,
status: "Draft",
},
];
type ModuleDetailState = {
moduleName?: string;
moduleDescription?: string;
};
export function ModuleDetailPage() {
const navigate = useNavigate();
const location = useLocation();
const navState = location.state as ModuleDetailState | null;
const { level, courseId, moduleId } = useParams<{
level: string;
courseId: string;
moduleId: string;
}>();
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
const [activeFilter, setActiveFilter] = useState("Draft");
const [lessons, setLessons] = useState<TopLevelModuleLessonItem[]>([]);
const [lessonsLoading, setLessonsLoading] = useState(true);
const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null);
const [editingLesson, setEditingLesson] =
useState<TopLevelModuleLessonItem | null>(null);
const [editLessonTitle, setEditLessonTitle] = useState("");
const [editLessonVideoUrl, setEditLessonVideoUrl] = useState("");
const [editLessonThumbnail, setEditLessonThumbnail] = useState("");
const [editLessonDescription, setEditLessonDescription] = useState("");
const [savingLessonEdit, setSavingLessonEdit] = useState(false);
const [thumbUploadBusy, setThumbUploadBusy] = useState(false);
const [videoUploadBusy, setVideoUploadBusy] = useState(false);
const lessonMediaUploadBusy = thumbUploadBusy || videoUploadBusy;
const [deletingLesson, setDeletingLesson] =
useState<TopLevelModuleLessonItem | null>(null);
const [deletingLessonInFlight, setDeletingLessonInFlight] = useState(false);
const [practices] = useState(MOCK_PRACTICES);
const [loadedModuleName, setLoadedModuleName] = useState<string | null>(null);
const [loadedModuleDescription, setLoadedModuleDescription] = useState<
string | null
>(null);
const [moduleListResolved, setModuleListResolved] = useState(
Boolean(navState?.moduleName?.trim()),
);
const moduleTitleFallback =
moduleId
?.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ") || "Module";
const displayModuleName =
navState?.moduleName?.trim() ||
loadedModuleName ||
moduleTitleFallback;
const hasNavName = Boolean(navState?.moduleName?.trim());
const displayModuleDescription = (() => {
if (hasNavName) {
return navState?.moduleDescription?.trim() || "—";
}
if (!moduleListResolved) {
return "Loading…";
}
if (loadedModuleDescription !== null) {
return loadedModuleDescription.trim() || "—";
}
return "—";
})();
useEffect(() => {
if (navState?.moduleName?.trim()) {
return;
}
const id = Number(moduleId);
const cid = Number(courseId);
if (!Number.isFinite(id) || id < 1 || !Number.isFinite(cid) || cid < 1) {
setModuleListResolved(true);
return;
}
let cancelled = false;
(async () => {
try {
const res = await getTopLevelCourseModules(cid, { limit: 100, offset: 0 });
if (cancelled) return;
const list = res.data?.data?.modules;
if (Array.isArray(list)) {
const m = list.find((mod) => mod.id === id);
if (m) {
setLoadedModuleName(m.name);
setLoadedModuleDescription(m.description ?? "");
} else {
setLoadedModuleName(null);
setLoadedModuleDescription("");
}
} else {
setLoadedModuleName(null);
setLoadedModuleDescription(null);
}
} catch {
if (!cancelled) {
setLoadedModuleName(null);
setLoadedModuleDescription(null);
}
} finally {
if (!cancelled) {
setModuleListResolved(true);
}
}
})();
return () => {
cancelled = true;
};
}, [navState?.moduleName, courseId, moduleId]);
const loadModuleLessons = useCallback(
async (options?: { showPageLoading?: boolean }) => {
const showPageLoading = options?.showPageLoading ?? true;
const mid = Number(moduleId);
if (!Number.isFinite(mid) || mid < 1) {
setLessons([]);
setLessonsLoadError(null);
setLessonsLoading(false);
return;
}
if (showPageLoading) {
setLessonsLoading(true);
setLessonsLoadError(null);
}
try {
const res = await getModuleLessons(mid, { limit: 100, offset: 0 });
const list = res.data?.data?.lessons;
if (Array.isArray(list)) {
setLessons(
[...list].sort(
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
),
);
} else {
setLessons([]);
}
if (showPageLoading) {
setLessonsLoadError(null);
}
} catch {
if (showPageLoading) {
setLessons([]);
setLessonsLoadError("Failed to load lessons. Please try again.");
} else {
toast.error("Failed to refresh lessons");
}
} finally {
if (showPageLoading) {
setLessonsLoading(false);
}
}
},
[moduleId],
);
useEffect(() => {
void loadModuleLessons({ showPageLoading: true });
}, [loadModuleLessons]);
const openEditLesson = (lesson: TopLevelModuleLessonItem) => {
setEditingLesson(lesson);
setEditLessonTitle(lesson.title ?? "");
setEditLessonVideoUrl(lesson.video_url ?? "");
setEditLessonThumbnail(lesson.thumbnail ?? "");
setEditLessonDescription(lesson.description ?? "");
};
const closeEditLesson = () => {
if (savingLessonEdit || lessonMediaUploadBusy) return;
setEditingLesson(null);
};
const handleSaveLessonEdit = async () => {
if (!editingLesson) return;
const title = editLessonTitle.trim();
if (!title) {
toast.error("Title is required");
return;
}
setSavingLessonEdit(true);
try {
await updateTopLevelModuleLesson(editingLesson.id, {
title,
video_url: editLessonVideoUrl.trim(),
thumbnail: editLessonThumbnail.trim(),
description: editLessonDescription.trim(),
});
toast.success("Lesson updated");
setEditingLesson(null);
await loadModuleLessons({ showPageLoading: false });
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update lesson";
toast.error(msg);
} finally {
setSavingLessonEdit(false);
}
};
const handleConfirmDeleteLesson = async () => {
if (!deletingLesson) return;
setDeletingLessonInFlight(true);
try {
await deleteTopLevelModuleLesson(deletingLesson.id);
toast.success("Lesson deleted");
setDeletingLesson(null);
await loadModuleLessons({ showPageLoading: false });
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to delete lesson";
toast.error(msg);
} finally {
setDeletingLessonInFlight(false);
}
};
return (
<div className="space-y-10 pt-10 pb-20 animate-in fade-in duration-500">
{/* Header Navigation */}
<div className="flex items-center gap-2">
<Link
to={`/new-content/learn-english/${level}/courses/${courseId}`}
className="flex items-center gap-2 text-[15px] font-medium text-grayScale-600 transition-colors hover:text-brand-500"
>
<ArrowLeft className="h-5 w-5" />
Back to Modules
</Link>
</div>
{/* Hero Section */}
<div className="flex flex-col md:flex-row md:items-start justify-between gap-6">
<div className="">
<h1 className="text-2xl font-medium text-grayScale-900 tracking-tight">
{displayModuleName}
</h1>
<p className="text-grayScale-500 text-[14px] max-w-2xl">
{displayModuleDescription}
</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
className="rounded-[6px] border-brand-500 text-brand-500 "
onClick={() =>
navigate(
`/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}`,
)
}
>
<Calendar className="h-4 w-4" />
Add Practice
</Button>
<Button
className="rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"
onClick={() =>
navigate(
`/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/add-video`,
)
}
>
<div className="h-4 w-4 flex items-center justify-center">
<span className="text-xl leading-none font-light">+</span>
</div>
Add Lesson
</Button>
</div>
</div>
{/* Tabs */}
<div className="border-b border-grayScale-200">
<div className="flex gap-10">
<button
onClick={() => setActiveTab("video")}
className={cn(
"pb-4 text-[16px] font-medium transition-all relative",
activeTab === "video"
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
: "text-grayScale-400 hover:text-grayScale-600",
)}
>
Lesson
</button>
<button
onClick={() => setActiveTab("practice")}
className={cn(
"pb-4 text-[16px] font-medium transition-all relative",
activeTab === "practice"
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
: "text-grayScale-400 hover:text-grayScale-600",
)}
>
Practice
</button>
</div>
</div>
{/* Content */}
<div className="mt-8">
{activeTab === "video" ? (
lessonsLoading ? (
<div className="flex flex-col items-center justify-center py-24 text-grayScale-500 text-[15px] font-medium">
Loading lessons
</div>
) : lessonsLoadError ? (
<div className="rounded-2xl border border-amber-100 bg-amber-50/80 px-6 py-8 text-center text-sm text-amber-900 max-w-lg mx-auto">
{lessonsLoadError}
</div>
) : lessons.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{lessons.map((lesson, i) => (
<VideoCard
key={lesson.id}
id={lesson.id}
title={lesson.title}
videoUrl={lesson.video_url}
hoverModuleActions
thumbnailUrl={resolveThumbnailForPreview(lesson.thumbnail)}
thumbnailGradient={LESSON_THUMB_GRADIENTS[i % LESSON_THUMB_GRADIENTS.length]}
onEdit={() => openEditLesson(lesson)}
onDelete={() => setDeletingLesson(lesson)}
/>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-32 px-4 rounded-[40px] border-2 border-dashed border-[#F1F5F9] bg-white max-w-4xl mx-auto shadow-sm">
<div className="h-20 w-20 rounded-full bg-[#FAF5FF] flex items-center justify-center mb-6">
<div className="h-14 w-14 rounded-full bg-[#F5EBFF] flex items-center justify-center">
<Video className="h-7 w-7 text-brand-500 fill-brand-500/10" />
</div>
</div>
<h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
No lessons in this module yet
</h2>
<p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed">
Lessons are a great way to engage students. Add your first
lesson to get started.
</p>
<Button
variant="outline"
className="h-12 px-8 rounded-xl border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2"
onClick={() =>
navigate(
`/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/add-video`,
)
}
>
<Video className="h-5 w-5" />
Add Lesson
</Button>
</div>
)
) : (
<div className="space-y-8">
{/* Practice Tab Filter Bar */}
<div className="bg-white border border-grayScale-100 rounded-2xl p-4 flex items-center gap-10 shadow-sm overflow-x-auto whitespace-nowrap px-8">
<div className="flex items-center gap-2 text-[12px] font-bold text-grayScale-300 uppercase tracking-widest mr-2">
STATUS:
</div>
<div className="flex items-center gap-3">
{["All", "Published", "Draft", "Archived"].map((label) => (
<button
key={label}
onClick={() => setActiveFilter(label)}
className={cn(
"h-9 px-5 rounded-full text-[13px] font-bold transition-all",
activeFilter === label
? "bg-brand-500 text-white shadow-md shadow-brand-500/20"
: "bg-[#F1F5F9] text-grayScale-500 hover:bg-grayScale-100",
)}
>
{label}
</button>
))}
</div>
</div>
{/* Practice Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{practices.map((practice) => (
<PracticeCard key={practice.id} {...practice} />
))}
</div>
</div>
)}
</div>
<Dialog
open={editingLesson !== null}
onOpenChange={(open) => {
if (!open && (savingLessonEdit || lessonMediaUploadBusy)) return;
if (!open) closeEditLesson();
}}
>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit lesson</DialogTitle>
<DialogDescription>
Update details. Video and thumbnail files use{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /files/upload
</code>
; the form is saved with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /lessons/:id
</code>
.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="space-y-2">
<label
className="text-sm font-medium text-grayScale-700"
htmlFor="edit-lesson-title"
>
Title
</label>
<Input
id="edit-lesson-title"
value={editLessonTitle}
onChange={(e) => setEditLessonTitle(e.target.value)}
disabled={savingLessonEdit}
/>
</div>
<LessonMediaUploadField
kind="video"
value={editLessonVideoUrl}
onChange={setEditLessonVideoUrl}
disabled={savingLessonEdit}
onUploadBusyChange={setVideoUploadBusy}
/>
<LessonMediaUploadField
kind="thumbnail"
value={editLessonThumbnail}
onChange={setEditLessonThumbnail}
disabled={savingLessonEdit}
onUploadBusyChange={setThumbUploadBusy}
/>
<div className="space-y-2">
<label
className="text-sm font-medium text-grayScale-700"
htmlFor="edit-lesson-desc"
>
Description
</label>
<Textarea
id="edit-lesson-desc"
value={editLessonDescription}
onChange={(e) => setEditLessonDescription(e.target.value)}
rows={4}
disabled={savingLessonEdit}
className="min-h-[100px] resize-y"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={closeEditLesson}
disabled={savingLessonEdit || lessonMediaUploadBusy}
>
Cancel
</Button>
<Button
type="button"
onClick={() => void handleSaveLessonEdit()}
disabled={savingLessonEdit || lessonMediaUploadBusy}
>
{savingLessonEdit ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{deletingLesson && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
<h2 className="text-lg font-bold text-grayScale-700">
Delete lesson
</h2>
<button
type="button"
onClick={() =>
!deletingLessonInFlight && setDeletingLesson(null)
}
disabled={deletingLessonInFlight}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 disabled:pointer-events-none disabled:opacity-50"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="px-6 py-6">
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
<Trash2 className="h-5 w-5 text-red-500" />
</div>
<p className="text-center text-sm leading-relaxed text-grayScale-600">
Are you sure you want to delete{" "}
<span className="font-semibold text-grayScale-700">
{deletingLesson.title}
</span>
? This cannot be undone.
</p>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => setDeletingLesson(null)}
disabled={deletingLessonInFlight}
className="w-full sm:w-auto"
>
Cancel
</Button>
<Button
type="button"
className="w-full bg-red-500 shadow-sm transition-all hover:bg-red-600 hover:shadow-md sm:w-auto"
disabled={deletingLessonInFlight}
onClick={() => void handleConfirmDeleteLesson()}
>
{deletingLessonInFlight ? "Deleting…" : "Delete"}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
function PracticeCard({
title,
level,
variations,
status,
}: {
title: string;
level: string;
variations: number;
status: string;
}) {
return (
<div className="bg-white rounded-[24px] border border-grayScale-50 shadow-sm overflow-hidden hover:shadow-xl hover:shadow-grayScale-400/5 transition-all group p-6 flex flex-col h-full min-h-[340px]">
<div className="flex-1 space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-[18px] font-bold text-grayScale-900 line-clamp-1">
{title}
</h3>
</div>
<div className="flex items-center justify-between gap-3">
<span className="bg-[#22C55E] text-white text-[11px] font-bold px-2 py-1 rounded-[4px]">
{level}
</span>
<div className="flex items-center gap-1.5 text-grayScale-500">
<Mic className="h-4 w-4" />
<span className="text-[13px] font-bold">Speaking</span>
</div>
</div>
<div className="flex items-center gap-2.5 text-brand-400 w-fit py-2 rounded-xl">
<Layers className="h-4 w-4" />
<span className="text-[14px] font-bold">{variations} Variations</span>
</div>
<div className="flex border-t border-grayScale-200 items-center justify-between pt-2">
<div className="bg-grayScale-100 text-grayScale-400 text-[11px] font-bold px-3 py-1.5 rounded-[6px] tracking-wide uppercase">
{status}
</div>
<div className="flex items-center gap-3">
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-400 hover:text-brand-500 hover:border-brand-100 transition-all">
<Edit2 className="h-5 w-5" />
</button>
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-400 hover:text-red-500 hover:border-red-100 transition-all">
<Trash2 className="h-5 w-5" />
</button>
</div>
</div>
</div>
<div className="mt-8 grid grid-cols-2 gap-3">
<Button className="bg-brand-500 text-white rounded-xl h-11 text-[13px] font-bold shadow-md shadow-brand-500/10 hover:bg-brand-600 transition-all px-0">
Publish Practice
</Button>
<Button
variant="outline"
className="border-brand-500 text-brand-500 rounded-xl h-11 text-[13px] font-bold bg-white hover:bg-brand-50 transition-all px-0"
>
Publish Video
</Button>
</div>
</div>
);
}