Yimaru-Admin/src/pages/content-management/CourseModuleDetailPage.tsx
Yared Yemane b8a73c73db feat(content): admin UX for forms, practices, lessons, and content hub
Remove description fields from course, unit, and module create/edit dialogs. Add unit sort order on create, lesson publish status and sort order, video duration on lesson cards, and personas API integration for Learn English practice flows.

Move Manage Question Types to the new content hub, add Reorder Content page with hierarchy drag-and-drop, shared practice review UI, module practice cards, and publish-practice controls on course listings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 08:00:31 -07:00

1026 lines
40 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowLeft, Plus, FileText, Pencil, Trash2 } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Button } from "../../components/ui/button";
import { cn } from "../../lib/utils";
import { Card } from "../../components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
import uploadIcon from "../../assets/icons/upload.png";
import { toast } from "sonner";
import { ResolvedImage } from "../../components/media/ResolvedImage";
import { VideoCard } from "./components/VideoCard";
import {
createExamPrepModuleLesson,
updateExamPrepModuleLesson,
deleteExamPrepModuleLesson,
getExamPrepModuleLessons,
} from "../../api/courses.api";
import { uploadImageFile, uploadVideoFile } from "../../api/files.api";
import type { PracticePublishStatus } from "../../types/course.types";
const MOCK_PRACTICES = [
{
id: "p1",
title: "1.1 Conversation Practice",
duration: "08:45",
status: "Published",
thumbnailColor: "bg-[#E0F2FE]",
},
{
id: "p2",
title: "1.2 Roleplay Scenario",
duration: "08:45",
status: "Draft",
thumbnailColor: "bg-[#F0FDF4]",
},
];
export function CourseModuleDetailPage() {
const navigate = useNavigate();
const { programType, courseId, unitId, moduleId } = useParams<{
programType: string;
courseId: string;
unitId: string;
moduleId: string;
}>();
const parsedModuleId = Number(moduleId);
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
const [lessonsLoading, setLessonsLoading] = useState(false);
const [lessons, setLessons] = useState<
Array<{
id: number;
title: string;
videoUrl: string;
description: string;
thumbnail: string;
sortOrder: number;
gradient: string;
durationSeconds: number | null;
}>
>([]);
const [createLessonOpen, setCreateLessonOpen] = useState(false);
const [createTitle, setCreateTitle] = useState("");
const [createVideoUrl, setCreateVideoUrl] = useState("");
const [createThumbnail, setCreateThumbnail] = useState("");
const [createDescription, setCreateDescription] = useState("");
const [creatingLesson, setCreatingLesson] = useState(false);
const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
const [uploadingVideo, setUploadingVideo] = useState(false);
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
const createVideoFileInputRef = useRef<HTMLInputElement>(null);
const [editingLessonId, setEditingLessonId] = useState<number | null>(null);
const [editTitle, setEditTitle] = useState("");
const [editVideoUrl, setEditVideoUrl] = useState("");
const [editThumbnail, setEditThumbnail] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editSortOrder, setEditSortOrder] = useState("1");
const [savingEdit, setSavingEdit] = useState(false);
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
const [uploadingEditVideo, setUploadingEditVideo] = useState(false);
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
const editVideoFileInputRef = useRef<HTMLInputElement>(null);
const [deletingLessonId, setDeletingLessonId] = useState<number | null>(null);
const [deletingLesson, setDeletingLesson] = useState(false);
const moduleTitle = "Module 1: Basic Phrases";
const moduleDescription = "Learn essential phrases for daily conversations.";
const isHttpUrl = (value: string) =>
value.startsWith("http://") || value.startsWith("https://");
const isMinioUrl = (value: string) => {
try {
const url = new URL(value);
return url.host === "s3.yimaruacademy.com";
} catch {
return false;
}
};
const resolveThumbnailToMinioUrl = async (rawValue: string) => {
const trimmed = rawValue.trim();
if (!trimmed) return "";
if (!isHttpUrl(trimmed) || isMinioUrl(trimmed)) return trimmed;
const uploaded = await uploadImageFile(trimmed);
const uploadedUrl = uploaded.data?.data?.url?.trim();
if (!uploadedUrl) throw new Error("Failed to upload thumbnail URL to MinIO");
return uploadedUrl;
};
const loadLessons = useCallback(async () => {
if (!Number.isFinite(parsedModuleId) || parsedModuleId < 1) {
setLessons([]);
return;
}
setLessonsLoading(true);
try {
const response = await getExamPrepModuleLessons(parsedModuleId, {
limit: 20,
offset: 0,
});
const rows = response.data?.data?.lessons;
const list = Array.isArray(rows) ? rows : [];
setLessons(
list.map((row, index) => {
const raw = row.duration_seconds ?? row.duration ?? null;
const n =
raw == null ? NaN : typeof raw === "number" ? raw : Number(raw);
const durationSeconds =
Number.isFinite(n) && n > 0 ? n : null;
return {
id: Number(row.id),
title: row.title?.trim() || `Lesson ${row.id}`,
videoUrl: row.video_url?.trim() || "",
description: row.description?.trim() || "—",
thumbnail: row.thumbnail?.trim() || "",
sortOrder: Number(row.sort_order ?? 0),
durationSeconds,
gradient:
index % 3 === 1
? "linear-gradient(135deg, rgba(79, 70, 229, 0.35) 0%, rgba(79, 70, 229, 0.6) 100%)"
: index % 3 === 2
? "linear-gradient(135deg, rgba(124, 58, 237, 0.35) 0%, rgba(124, 58, 237, 0.6) 100%)"
: "linear-gradient(135deg, rgba(158, 40, 145, 0.35) 0%, rgba(158, 40, 145, 0.6) 100%)",
};
}),
);
} catch (error) {
console.error(error);
toast.error("Failed to load lessons");
setLessons([]);
} finally {
setLessonsLoading(false);
}
}, [parsedModuleId]);
useEffect(() => {
if (activeTab !== "video") return;
void loadLessons();
}, [activeTab, loadLessons]);
const clearCreateLessonForm = () => {
setCreateTitle("");
setCreateVideoUrl("");
setCreateThumbnail("");
setCreateDescription("");
if (createThumbnailFileInputRef.current) {
createThumbnailFileInputRef.current.value = "";
}
if (createVideoFileInputRef.current) {
createVideoFileInputRef.current.value = "";
}
};
const handleCreateLessonVideoFile = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("video/")) {
toast.error("Please choose a video file");
return;
}
setUploadingVideo(true);
try {
const res = await uploadVideoFile(file, {
title: createTitle.trim() || "Lesson video",
description: createDescription.trim() || undefined,
});
const finalUrl =
res.data?.data?.url?.trim() || res.data?.data?.embed_url?.trim() || "";
if (!finalUrl) throw new Error("Upload did not return a video URL");
setCreateVideoUrl(finalUrl);
toast.success("Video uploaded");
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload video";
toast.error(message);
} finally {
setUploadingVideo(false);
}
};
const handleCreateLessonThumbnailFile = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("Please choose an image file");
return;
}
setUploadingThumbnail(true);
try {
const res = await uploadImageFile(file);
const url = res.data?.data?.url?.trim();
if (!url) throw new Error("Upload did not return a file URL");
setCreateThumbnail(url);
toast.success("Thumbnail uploaded");
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload thumbnail";
toast.error(message);
} finally {
setUploadingThumbnail(false);
}
};
const autoUploadCreateThumbnailUrl = async (rawValue: string) => {
const trimmed = rawValue.trim();
if (!trimmed || !isHttpUrl(trimmed) || isMinioUrl(trimmed)) return;
setUploadingThumbnail(true);
try {
const minioUrl = await resolveThumbnailToMinioUrl(trimmed);
if (minioUrl && minioUrl !== trimmed) {
setCreateThumbnail(minioUrl);
toast.success("Thumbnail uploaded to MinIO");
}
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload URL to MinIO";
toast.error(message);
} finally {
setUploadingThumbnail(false);
}
};
const handleCreateLesson = async (publishStatus: PracticePublishStatus) => {
if (!Number.isFinite(parsedModuleId) || parsedModuleId < 1) {
toast.error("Invalid module");
return;
}
const title = createTitle.trim();
const videoUrl = createVideoUrl.trim();
if (!title) {
toast.error("Lesson title is required");
return;
}
if (!videoUrl) {
toast.error("Video URL is required");
return;
}
setCreatingLesson(true);
try {
const minioThumbnail = await resolveThumbnailToMinioUrl(createThumbnail);
await createExamPrepModuleLesson(parsedModuleId, {
title,
video_url: videoUrl,
thumbnail: minioThumbnail || null,
description: createDescription.trim() || null,
publish_status: publishStatus,
});
await loadLessons();
toast.success(
publishStatus === "DRAFT"
? "Lesson saved as draft"
: "Lesson created",
);
clearCreateLessonForm();
setCreateLessonOpen(false);
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to create lesson";
toast.error(message);
} finally {
setCreatingLesson(false);
}
};
const openEditLesson = (lesson: (typeof lessons)[number]) => {
setEditingLessonId(lesson.id);
setEditTitle(lesson.title ?? "");
setEditVideoUrl(lesson.videoUrl ?? "");
setEditThumbnail(lesson.thumbnail ?? "");
setEditDescription(lesson.description ?? "");
setEditSortOrder(String(lesson.sortOrder ?? 1));
};
const closeEditLesson = () => {
if (savingEdit || uploadingEditThumbnail || uploadingEditVideo) return;
setEditingLessonId(null);
setEditTitle("");
setEditVideoUrl("");
setEditThumbnail("");
setEditDescription("");
setEditSortOrder("1");
};
const handleEditLessonVideoFile = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("video/")) {
toast.error("Please choose a video file");
return;
}
setUploadingEditVideo(true);
try {
const res = await uploadVideoFile(file, {
title: editTitle.trim() || "Lesson video",
description: editDescription.trim() || undefined,
});
const finalUrl =
res.data?.data?.url?.trim() || res.data?.data?.embed_url?.trim() || "";
if (!finalUrl) throw new Error("Upload did not return a video URL");
setEditVideoUrl(finalUrl);
toast.success("Video uploaded");
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload video";
toast.error(message);
} finally {
setUploadingEditVideo(false);
}
};
const handleEditLessonThumbnailFile = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("Please choose an image file");
return;
}
setUploadingEditThumbnail(true);
try {
const res = await uploadImageFile(file);
const url = res.data?.data?.url?.trim();
if (!url) throw new Error("Upload did not return a file URL");
setEditThumbnail(url);
toast.success("Thumbnail uploaded");
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload thumbnail";
toast.error(message);
} finally {
setUploadingEditThumbnail(false);
}
};
const autoUploadEditThumbnailUrl = async (rawValue: string) => {
const trimmed = rawValue.trim();
if (!trimmed || !isHttpUrl(trimmed) || isMinioUrl(trimmed)) return;
setUploadingEditThumbnail(true);
try {
const minioUrl = await resolveThumbnailToMinioUrl(trimmed);
if (minioUrl && minioUrl !== trimmed) {
setEditThumbnail(minioUrl);
toast.success("Thumbnail uploaded to MinIO");
}
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload URL to MinIO";
toast.error(message);
} finally {
setUploadingEditThumbnail(false);
}
};
const handleSaveEditLesson = async () => {
if (!editingLessonId) return;
const title = editTitle.trim();
if (!title) {
toast.error("Lesson title is required");
return;
}
const sortOrderNum = Number(editSortOrder);
if (!Number.isFinite(sortOrderNum) || sortOrderNum < 0) {
toast.error("Sort order must be a valid number");
return;
}
setSavingEdit(true);
try {
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
await updateExamPrepModuleLesson(editingLessonId, {
title,
video_url: editVideoUrl.trim() || null,
thumbnail: minioThumbnail || null,
description: editDescription.trim() || null,
sort_order: sortOrderNum,
});
await loadLessons();
toast.success("Lesson updated");
closeEditLesson();
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update lesson";
toast.error(message);
} finally {
setSavingEdit(false);
}
};
const handleDeleteLesson = async () => {
if (!deletingLessonId) return;
setDeletingLesson(true);
try {
await deleteExamPrepModuleLesson(deletingLessonId);
await loadLessons();
toast.success("Lesson deleted");
setDeletingLessonId(null);
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to delete lesson";
toast.error(message);
} finally {
setDeletingLesson(false);
}
};
return (
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
{/* Navigation */}
<Link
to={`/new-content/courses/${programType}/${courseId}/${unitId}`}
className="flex items-center gap-2.5 text-[15px] font-bold text-grayScale-600 hover:text-brand-500 transition-colors pt-4 group"
>
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
Back to Modules
</Link>
{/* Header section */}
<div className="flex items-start justify-between">
<div className="space-y-2">
<h1 className="text-[32px] font-extrabold tracking-tight text-[#0D1421]">
{moduleTitle}
</h1>
<p className="max-w-2xl text-[16px] font-medium leading-relaxed text-grayScale-400">
{moduleDescription}
</p>
</div>
<div className="flex items-center gap-3 pt-2">
<Button
variant="outline"
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2 shadow-sm"
onClick={() =>
navigate(
`/new-content/courses/${programType}/${courseId}/unit/${unitId}/module/${moduleId}/attach-practice`,
)
}
>
<FileText className="h-5 w-5" />
Attach Practice
</Button>
<Dialog
open={createLessonOpen}
onOpenChange={(open) => {
if (!open && (creatingLesson || uploadingThumbnail || uploadingVideo))
return;
setCreateLessonOpen(open);
}}
>
<DialogTrigger asChild>
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-md hover:bg-brand-600 transition-all flex items-center gap-2 text-[15px]">
<Plus className="h-5 w-5" />
Add Lesson
</Button>
</DialogTrigger>
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
<div className="flex min-h-0 flex-1 flex-col bg-white">
<DialogHeader className="shrink-0 px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
Create Lesson
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto p-8 space-y-8">
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Lesson Title
</label>
<Input
value={createTitle}
onChange={(e) => setCreateTitle(e.target.value)}
placeholder="e.g. Intro lesson"
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Video URL
</label>
<input
ref={createVideoFileInputRef}
type="file"
accept="video/*"
className="sr-only"
onChange={(e) => void handleCreateLessonVideoFile(e)}
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
/>
<button
type="button"
className="relative group w-full cursor-pointer"
onClick={() => createVideoFileInputRef.current?.click()}
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
>
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all">
<div className="mb-4">
<img
src={uploadIcon}
alt="Upload icon"
className="h-10 w-10"
/>
</div>
<p className="text-[15px]">
<span className="text-brand-500 font-bold hover:underline">
{uploadingVideo ? "Uploading…" : "Click to upload"}
</span>{" "}
<span className="text-grayScale-500">
video from your computer
</span>
</p>
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
MP4, MOV, WEBM
</p>
</div>
</button>
<Input
value={createVideoUrl}
onChange={(e) => setCreateVideoUrl(e.target.value)}
placeholder="https://example.com/video"
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Description
</label>
<Textarea
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="Optional lesson description"
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Thumbnail
</label>
<input
ref={createThumbnailFileInputRef}
type="file"
accept="image/*"
className="sr-only"
onChange={(e) => void handleCreateLessonThumbnailFile(e)}
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
/>
<button
type="button"
className="relative group w-full cursor-pointer"
onClick={() => createThumbnailFileInputRef.current?.click()}
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
>
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all">
<div className="mb-4">
<img
src={uploadIcon}
alt="Upload icon"
className="h-10 w-10"
/>
</div>
<p className="text-[15px]">
<span className="text-brand-500 font-bold hover:underline">
{uploadingThumbnail ? "Uploading…" : "Click to upload"}
</span>{" "}
<span className="text-grayScale-500">
or paste a URL below
</span>
</p>
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
JPG, PNG (MAX 5 MB)
</p>
</div>
</button>
{createThumbnail.trim() ? (
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
<ResolvedImage
src={createThumbnail.trim()}
alt=""
className="h-28 w-full object-cover"
/>
</div>
) : null}
<Input
value={createThumbnail}
onChange={(e) => setCreateThumbnail(e.target.value)}
onPaste={(event) => {
const pasted = event.clipboardData?.getData("text")?.trim();
if (!pasted) return;
setTimeout(() => {
void autoUploadCreateThumbnailUrl(pasted);
}, 0);
}}
placeholder="Optional thumbnail URL (or leave empty for null)"
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
/>
</div>
</div>
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex flex-wrap justify-end gap-3">
<DialogClose asChild>
<Button
type="button"
variant="outline"
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
onClick={clearCreateLessonForm}
>
Cancel
</Button>
</DialogClose>
<Button
type="button"
variant="outline"
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold hover:bg-grayScale-50"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
onClick={() => void handleCreateLesson("DRAFT")}
>
{creatingLesson ? "Saving…" : "Save as draft"}
</Button>
<Button
type="button"
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
onClick={() => void handleCreateLesson("PUBLISHED")}
>
{creatingLesson ? "Creating..." : "Publish lesson"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</div>
{/* Tabs */}
<div className="flex gap-10 border-b border-grayScale-100">
<button
onClick={() => setActiveTab("video")}
className={cn(
"pb-4 text-[16px] font-bold transition-all relative px-2",
activeTab === "video"
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-brand-500"
: "text-grayScale-400 hover:text-grayScale-600",
)}
>
Lesson
</button>
<button
onClick={() => setActiveTab("practice")}
className={cn(
"pb-4 text-[16px] font-bold transition-all relative px-2",
activeTab === "practice"
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-brand-500"
: "text-grayScale-400 hover:text-grayScale-600",
)}
>
Practice
</button>
</div>
{/* Grid of Content */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 pt-4">
{activeTab === "video" ? (
lessonsLoading ? (
<p className="text-sm text-grayScale-500">Loading lessons...</p>
) : lessons.length === 0 ? (
<div className="col-span-full rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
<p className="text-sm font-medium text-grayScale-600">
No lessons for this module yet
</p>
<p className="mt-1 text-sm text-grayScale-400">
Create your first lesson to start building this module.
</p>
</div>
) : (
lessons.map((lesson) => (
<VideoCard
key={lesson.id}
title={lesson.title}
thumbnailUrl={lesson.thumbnail}
videoUrl={lesson.videoUrl}
thumbnailGradient={lesson.gradient}
durationSeconds={lesson.durationSeconds}
hoverModuleActions
onEdit={() => openEditLesson(lesson)}
onDelete={() => setDeletingLessonId(lesson.id)}
description={lesson.description}
/>
))
)
) : (
MOCK_PRACTICES.map((item) => <PracticeCard key={item.id} {...item} />)
)}
</div>
<Dialog
open={editingLessonId !== null}
onOpenChange={(open) => {
if (!open && (savingEdit || uploadingEditThumbnail || uploadingEditVideo))
return;
if (!open) closeEditLesson();
}}
>
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
<div className="flex min-h-0 flex-1 flex-col bg-white">
<DialogHeader className="shrink-0 px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
Edit Lesson
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto p-8 space-y-8">
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Lesson Title</label>
<Input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Video URL</label>
<input
ref={editVideoFileInputRef}
type="file"
accept="video/*"
className="sr-only"
onChange={(e) => void handleEditLessonVideoFile(e)}
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
/>
<button
type="button"
className="relative group w-full cursor-pointer"
onClick={() => editVideoFileInputRef.current?.click()}
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
>
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all">
<div className="mb-4">
<img src={uploadIcon} alt="Upload icon" className="h-10 w-10" />
</div>
<p className="text-[15px]">
<span className="text-brand-500 font-bold hover:underline">
{uploadingEditVideo ? "Uploading…" : "Click to upload"}
</span>{" "}
<span className="text-grayScale-500">
video from your computer
</span>
</p>
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
MP4, MOV, WEBM
</p>
</div>
</button>
<Input
value={editVideoUrl}
onChange={(e) => setEditVideoUrl(e.target.value)}
placeholder="https://example.com/video"
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Description</label>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Sort Order</label>
<Input
type="number"
min={0}
value={editSortOrder}
onChange={(e) => setEditSortOrder(e.target.value)}
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
<input
ref={editThumbnailFileInputRef}
type="file"
accept="image/*"
className="sr-only"
onChange={(e) => void handleEditLessonThumbnailFile(e)}
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
/>
<button
type="button"
className="relative group w-full cursor-pointer"
onClick={() => editThumbnailFileInputRef.current?.click()}
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
>
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all">
<div className="mb-4">
<img src={uploadIcon} alt="Upload icon" className="h-10 w-10" />
</div>
<p className="text-[15px]">
<span className="text-brand-500 font-bold hover:underline">
{uploadingEditThumbnail ? "Uploading…" : "Click to upload"}
</span>{" "}
<span className="text-grayScale-500">or paste a URL below</span>
</p>
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
JPG, PNG (MAX 5 MB)
</p>
</div>
</button>
{editThumbnail.trim() ? (
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
<ResolvedImage
src={editThumbnail.trim()}
alt=""
className="h-28 w-full object-cover"
/>
</div>
) : null}
<Input
value={editThumbnail}
onChange={(e) => setEditThumbnail(e.target.value)}
onPaste={(event) => {
const pasted = event.clipboardData?.getData("text")?.trim();
if (!pasted) return;
setTimeout(() => {
void autoUploadEditThumbnailUrl(pasted);
}, 0);
}}
placeholder="Optional thumbnail URL (or leave empty for null)"
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
/>
</div>
</div>
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
<Button
type="button"
variant="outline"
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
onClick={closeEditLesson}
>
Cancel
</Button>
<Button
type="button"
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
onClick={() => void handleSaveEditLesson()}
>
{savingEdit ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog
open={deletingLessonId !== null}
onOpenChange={(open) => {
if (!open && !deletingLesson) setDeletingLessonId(null);
}}
>
<DialogContent className="max-w-md rounded-[16px] border-none p-0 overflow-hidden">
<div className="bg-white">
<DialogHeader className="border-b border-grayScale-100 px-6 py-5">
<DialogTitle className="text-lg font-bold text-grayScale-900">
Delete Lesson
</DialogTitle>
</DialogHeader>
<div className="px-6 py-6 text-sm text-grayScale-600">
Are you sure you want to delete this lesson? This action cannot be undone.
</div>
<div className="flex justify-end gap-3 border-t border-grayScale-100 px-6 py-4">
<Button
variant="outline"
onClick={() => setDeletingLessonId(null)}
disabled={deletingLesson}
>
Cancel
</Button>
<Button
className="bg-red-500 hover:bg-red-600"
onClick={() => void handleDeleteLesson()}
disabled={deletingLesson}
>
{deletingLesson ? "Deleting..." : "Delete"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
function PracticeCard({
title,
duration,
status,
thumbnailColor,
}: {
title: string;
duration: string;
status: string;
thumbnailColor: string;
}) {
return (
<Card className="group flex flex-col bg-white rounded-[20px] border border-grayScale-50 overflow-hidden shadow-sm hover:shadow-xl hover:shadow-grayScale-400/5 transition-all">
{/* Thumbnail Area */}
<div className={cn("h-44 w-full relative", thumbnailColor)}>
<div className="absolute bottom-3 right-3 bg-black/60 text-white text-[11px] font-bold px-2 py-0.5 rounded backdrop-blur-sm">
{duration}
</div>
</div>
<div className="p-5 flex flex-col flex-1 space-y-5">
<div className="flex items-center justify-between">
<div
className={cn(
"px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider flex items-center gap-2 border",
status === "Published"
? "bg-[#F0FDF4] text-[#16A34A] border-[#DCFCE7]"
: "bg-grayScale-50 text-grayScale-400 border-grayScale-100",
)}
>
<div
className={cn(
"h-1.5 w-1.5 rounded-full",
status === "Published" ? "bg-[#16A34A]" : "bg-grayScale-300",
)}
/>
{status}
</div>
<div />
</div>
<h3 className="text-[14px] font-bold text-[#0F172A] line-clamp-2 leading-snug">
{title}
</h3>
<div className="pt-2 grid grid-cols-1 gap-2 mt-auto">
<Button variant="outline" className="w-full h-10 rounded-[10px] border-grayScale-200 text-grayScale-600 font-bold text-xs">
Edit
</Button>
<Button
className={cn(
"w-full h-10 rounded-[10px] font-bold text-xs shadow-sm",
status === "Published"
? "bg-[#ECD5E9] text-[#9E2891] hover:bg-[#EBD0E7]"
: "bg-brand-500 text-white hover:bg-brand-600",
)}
>
{status === "Published" ? "Published" : "Publish"}
</Button>
</div>
</div>
</Card>
);
}