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>
1026 lines
40 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|