import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useParams, useNavigate } from "react-router-dom"; import { ArrowLeft, Plus, LayoutGrid, PlayCircle, ClipboardCheck, Pencil, Trash2, ChevronRight, ArrowRight, X, } from "lucide-react"; import { Button } from "../../components/ui/button"; import { Card } from "../../components/ui/card"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogClose, } from "../../components/ui/dialog"; import { Input } from "../../components/ui/input"; import uploadIcon from "../../assets/icons/upload.png"; import { toast } from "sonner"; import { ResolvedImage } from "../../components/media/ResolvedImage"; import { createExamPrepCatalogUnit, getExamPrepCatalogCourses, setExamPrepCatalogCoursePublishStatus, setExamPrepCatalogUnitAccessTier, setExamPrepCatalogUnitPublishStatus, updateExamPrepCatalogUnit, deleteExamPrepCatalogUnit, getExamPrepCatalogUnits, } from "../../api/courses.api"; import { uploadImageFile } from "../../api/files.api"; import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip"; import { ContentAccessTierChip } from "./components/ContentAccessTierChip"; import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar"; import { ContentPageDescription } from "./components/ContentPageDescription"; import type { ContentAccessTier, PracticePublishStatus } from "../../types/course.types"; import { filterBySearchAndPublishStatus, type PublishStatusFilter, } from "../../lib/contentListFilters"; export function CourseManagementPage() { const navigate = useNavigate(); const { programType, courseId } = useParams<{ programType: string; courseId: string; }>(); const catalogCourseId = Number(courseId); const [addUnitOpen, setAddUnitOpen] = useState(false); const [createName, setCreateName] = useState(""); const [createSortOrder, setCreateSortOrder] = useState(""); const [createThumbnail, setCreateThumbnail] = useState(""); const [creating, setCreating] = useState(false); const [uploadingThumbnail, setUploadingThumbnail] = useState(false); const createThumbnailFileInputRef = useRef(null); const [units, setUnits] = useState< Array<{ id: number; name: string; description: string; thumbnail: string; sortOrder: number; publishStatus: PracticePublishStatus | string | null; accessTier: ContentAccessTier | string | null; modules: number; lessons: number; practices: number; gradient: string; }> >([]); const [publishStatusUpdatingId, setPublishStatusUpdatingId] = useState< number | null >(null); const [accessTierUpdatingId, setAccessTierUpdatingId] = useState< number | null >(null); const [unitsLoading, setUnitsLoading] = useState(false); const [editingUnitId, setEditingUnitId] = useState(null); const [editName, setEditName] = useState(""); const [editThumbnail, setEditThumbnail] = useState(""); const [editSortOrder, setEditSortOrder] = useState("1"); const [savingEdit, setSavingEdit] = useState(false); const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false); const editThumbnailFileInputRef = useRef(null); const [deletingUnitId, setDeletingUnitId] = useState(null); const [deletingUnit, setDeletingUnit] = useState(false); const [listSearch, setListSearch] = useState(""); const [publishStatusFilter, setPublishStatusFilter] = useState("all"); const [catalogCourseName, setCatalogCourseName] = useState("Course"); const [catalogCourseDescription, setCatalogCourseDescription] = useState(""); const [catalogCoursePublishStatus, setCatalogCoursePublishStatus] = useState< PracticePublishStatus | string | null >(null); const [catalogCoursePublishStatusUpdating, setCatalogCoursePublishStatusUpdating] = useState(false); const filteredUnits = useMemo( () => filterBySearchAndPublishStatus(units, { search: listSearch, publishStatusFilter, getSearchFields: (u) => [u.name, u.description], getPublishStatus: (u) => u.publishStatus, }), [listSearch, publishStatusFilter, units], ); const courseDisplayName = catalogCourseName; const loadCatalogCourse = useCallback(async () => { if (!Number.isFinite(catalogCourseId) || catalogCourseId < 1) return; try { const response = await getExamPrepCatalogCourses({ limit: 100, offset: 0 }); const rows = response.data?.data?.catalog_courses; const list = Array.isArray(rows) ? rows : []; const row = list.find((c) => Number(c.id) === catalogCourseId); if (row) { setCatalogCourseName(row.name?.trim() || `Course ${catalogCourseId}`); setCatalogCourseDescription(row.description?.trim() || ""); setCatalogCoursePublishStatus(row.publish_status ?? null); } } catch (error) { console.error(error); } }, [catalogCourseId]); const loadUnits = useCallback(async () => { if (!Number.isFinite(catalogCourseId) || catalogCourseId < 1) { setUnits([]); return; } setUnitsLoading(true); try { const response = await getExamPrepCatalogUnits(catalogCourseId, { limit: 20, offset: 0, }); const rows = response.data?.data?.units; const list = Array.isArray(rows) ? rows : []; setUnits( list.map((row, index) => ({ id: Number(row.id), name: row.name?.trim() || `Unit ${row.id}`, description: row.description?.trim() || "—", thumbnail: row.thumbnail?.trim() || "", sortOrder: Number(row.sort_order ?? 0), publishStatus: row.publish_status ?? null, accessTier: row.access_tier ?? null, modules: Number(row.modules_count ?? 0), lessons: Number(row.lessons_count ?? row.videos_count ?? 0), practices: Number(row.practices_count ?? 0), gradient: index % 3 === 1 ? "linear-gradient(135deg, rgba(79, 70, 229, 0.5) 0%, rgba(79, 70, 229, 0.8) 100%)" : index % 3 === 2 ? "linear-gradient(135deg, rgba(124, 58, 237, 0.5) 0%, rgba(124, 58, 237, 0.8) 100%)" : "linear-gradient(135deg, rgba(158, 40, 145, 0.5) 0%, rgba(158, 40, 145, 0.8) 100%)", })), ); } catch (error) { console.error(error); toast.error("Failed to load units"); setUnits([]); } finally { setUnitsLoading(false); } }, [catalogCourseId]); useEffect(() => { void loadCatalogCourse(); }, [loadCatalogCourse]); useEffect(() => { void loadUnits(); }, [loadUnits]); const handleCatalogCoursePublishStatus = async ( nextStatus: PracticePublishStatus, ) => { if (!Number.isFinite(catalogCourseId) || catalogCourseId < 1) return; setCatalogCoursePublishStatusUpdating(true); try { await setExamPrepCatalogCoursePublishStatus(catalogCourseId, { publish_status: nextStatus, }); setCatalogCoursePublishStatus(nextStatus); toast.success( nextStatus === "PUBLISHED" ? "Course published" : "Course saved as draft", ); } catch (error: unknown) { const message = (error as { response?: { data?: { message?: string } } })?.response?.data ?.message ?? "Failed to update course status"; toast.error(message); } finally { setCatalogCoursePublishStatusUpdating(false); } }; const handleUnitPublishStatus = async ( unitId: number, nextStatus: PracticePublishStatus, ) => { setPublishStatusUpdatingId(unitId); try { await setExamPrepCatalogUnitPublishStatus(unitId, { publish_status: nextStatus, }); setUnits((prev) => prev.map((u) => u.id === unitId ? { ...u, publishStatus: nextStatus } : u, ), ); toast.success( nextStatus === "PUBLISHED" ? "Unit published" : "Unit saved as draft", ); } catch (error: unknown) { const message = (error as { response?: { data?: { message?: string } } })?.response?.data ?.message ?? "Failed to update unit status"; toast.error(message); } finally { setPublishStatusUpdatingId(null); } }; const handleUnitAccessTier = async ( unitId: number, nextTier: ContentAccessTier, ) => { setAccessTierUpdatingId(unitId); try { await setExamPrepCatalogUnitAccessTier(unitId, { access_tier: nextTier }); setUnits((prev) => prev.map((u) => u.id === unitId ? { ...u, accessTier: nextTier } : u, ), ); toast.success( nextTier === "PREMIUM" ? "Unit set to Premium" : "Unit set to Free", ); } catch (error: unknown) { const message = (error as { response?: { data?: { message?: string } } })?.response?.data ?.message ?? "Failed to update unit access tier"; toast.error(message); } finally { setAccessTierUpdatingId(null); } }; 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 clearCreateUnitForm = () => { setCreateName(""); setCreateSortOrder(""); setCreateThumbnail(""); if (createThumbnailFileInputRef.current) { createThumbnailFileInputRef.current.value = ""; } }; const handleCreateUnitThumbnailFile = async ( event: React.ChangeEvent, ) => { 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; } const maxBytes = 5 * 1024 * 1024; if (file.size > maxBytes) { toast.error("Image is too large", { description: "Maximum size is 5 MB." }); 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 handleCreateUnit = async () => { if (!Number.isFinite(catalogCourseId) || catalogCourseId < 1) { toast.error("Invalid catalog course"); return; } const name = createName.trim(); if (!name) { toast.error("Unit name is required"); return; } const sortOrderRaw = createSortOrder.trim(); if (!sortOrderRaw) { toast.error("Sort order is required"); return; } const sort_order = Number(sortOrderRaw); if (!Number.isInteger(sort_order) || sort_order < 0) { toast.error("Sort order must be a whole number of 0 or greater"); return; } setCreating(true); try { const minioThumbnail = await resolveThumbnailToMinioUrl(createThumbnail); const response = await createExamPrepCatalogUnit(catalogCourseId, { name, description: null, thumbnail: minioThumbnail || null, sort_order, }); void response; await loadUnits(); toast.success("Unit created"); clearCreateUnitForm(); setAddUnitOpen(false); } catch (error: unknown) { console.error(error); const message = (error as { response?: { data?: { message?: string } } })?.response?.data ?.message ?? "Failed to create unit"; toast.error(message); } finally { setCreating(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 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 openEditUnit = (unit: (typeof units)[number]) => { setEditingUnitId(unit.id); setEditName(unit.name ?? ""); setEditThumbnail(unit.thumbnail ?? ""); setEditSortOrder(String(unit.sortOrder ?? 0)); }; const closeEditUnit = () => { if (savingEdit || uploadingEditThumbnail) return; setEditingUnitId(null); setEditName(""); setEditThumbnail(""); setEditSortOrder(""); }; const handleEditUnitThumbnailFile = async ( event: React.ChangeEvent, ) => { 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 handleSaveEditUnit = async () => { if (!editingUnitId) return; const name = editName.trim(); if (!name) { toast.error("Unit name is required"); return; } const sortOrderRaw = editSortOrder.trim(); if (!sortOrderRaw) { toast.error("Sort order is required"); return; } const sort_order = Number(sortOrderRaw); if (!Number.isInteger(sort_order) || sort_order < 0) { toast.error("Sort order must be a whole number of 0 or greater"); return; } setSavingEdit(true); try { const existing = units.find((u) => u.id === editingUnitId); const preservedDescription = existing?.description && existing.description !== "—" ? existing.description : null; const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail); await updateExamPrepCatalogUnit(editingUnitId, { name, description: preservedDescription, thumbnail: minioThumbnail || null, sort_order, }); await loadUnits(); toast.success("Unit updated"); closeEditUnit(); } catch (error: unknown) { console.error(error); const message = (error as { response?: { data?: { message?: string } } })?.response?.data ?.message ?? "Failed to update unit"; toast.error(message); } finally { setSavingEdit(false); } }; const handleDeleteUnit = async () => { if (!deletingUnitId) return; setDeletingUnit(true); try { await deleteExamPrepCatalogUnit(deletingUnitId); await loadUnits(); toast.success("Unit deleted"); setDeletingUnitId(null); } catch (error: unknown) { console.error(error); const message = (error as { response?: { data?: { message?: string } } })?.response?.data ?.message ?? "Failed to delete unit"; toast.error(message); } finally { setDeletingUnit(false); } }; return (
{/* Navigation */} Back to Courses {/* Header section */}
void handleCatalogCoursePublishStatus(nextStatus) } />

{courseDisplayName}

{catalogCourseDescription ? ( {catalogCourseDescription} ) : (

Manage units and modules inside {courseDisplayName}

)}
{ if (!open && (creating || uploadingThumbnail)) return; setAddUnitOpen(open); }} >
Create Unit
setCreateName(e.target.value)} placeholder="e.g. Reading" className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20" disabled={creating || uploadingThumbnail} />
setCreateSortOrder(e.target.value)} placeholder="e.g. 0" className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20" disabled={creating || uploadingThumbnail} />

Lower numbers appear first when units are listed.

void handleCreateUnitThumbnailFile(e)} disabled={creating || uploadingThumbnail} /> {createThumbnail.trim() ? (
) : null} 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 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20" disabled={creating || uploadingThumbnail} />
{/* Horizontal Divider */}
); }