import { useCallback, useEffect, useMemo, useState } from "react"; import { ArrowLeft, Plus, Calendar, Layers, Pencil, Trash2, X, } from "lucide-react"; import { Link, useNavigate, useParams } from "react-router-dom"; import { toast } from "sonner"; import { Button } from "../../components/ui/button"; import { Card } from "../../components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../../components/ui/dialog"; import { Input } from "../../components/ui/input"; import { cn } from "../../lib/utils"; import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"; import alertSrc from "../../assets/Alert.svg"; import { deleteTopLevelCourseModule, getPracticesByParentCourse, getProgramCourses, getTopLevelCourseModules, publishParentLinkedPractice, updateParentLinkedPractice, updateTopLevelCourseModule, } from "../../api/courses.api"; import { refreshFileUrl, resolveFileUrl } from "../../api/files.api"; import type { ParentContextPractice, ProgramCourseListItem, TopLevelCourseModuleItem, } from "../../types/course.types"; import { isPracticeDraft, isPracticePublished, unwrapPracticesList, } from "../../lib/parentContextPractice"; import { AddModuleModal } from "./components/AddModuleModal"; import { ModuleIconUploadField } from "./components/ModuleIconUploadField"; import { ModulePracticeCard } from "./components/ModulePracticeCard"; import { PublishPracticeButton } from "./components/PublishPracticeButton"; const MODULE_CARD_GRADIENT = "from-[#8E44AD] to-[#C39BD3]" as const; function isLikelyImageUrl(src: string): boolean { const t = src.trim(); return ( t.startsWith("http://") || t.startsWith("https://") || t.startsWith("/") || t.startsWith("data:") ); } function isSignedMinioUrl(src: string): boolean { const value = src.trim(); if (!value.startsWith("http://") && !value.startsWith("https://")) return false; try { const url = new URL(value); return url.searchParams.has("X-Amz-Signature"); } catch { return false; } } /** Default purple gradient with optional cover image; gradient stays if URL missing or image errors. */ function ModuleCardTopMedia({ iconSrc }: { iconSrc: string }) { const [coverFailed, setCoverFailed] = useState(false); const tryCover = Boolean(iconSrc.trim()) && isLikelyImageUrl(iconSrc) && !coverFailed; return (
{tryCover ? ( setCoverFailed(true)} /> ) : null}
); } /** Circular module icon: image when load succeeds, otherwise default Layers icon. */ function ModuleIconCircle({ iconSrc, index, }: { iconSrc: string; index: number; }) { const [imgFailed, setImgFailed] = useState(false); const showImg = Boolean(iconSrc.trim()) && isLikelyImageUrl(iconSrc) && !imgFailed; return (
{showImg ? ( setImgFailed(true)} /> ) : ( )}
); } export function CourseDetailPage() { const navigate = useNavigate(); const { level: programIdParam, courseId: courseIdParam } = useParams<{ level: string; courseId: string; }>(); const programId = Number(programIdParam); const courseIdNum = Number(courseIdParam); const [course, setCourse] = useState(null); const [modules, setModules] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isAddModuleOpen, setIsAddModuleOpen] = useState(false); const [editingModule, setEditingModule] = useState(null); const [editModuleName, setEditModuleName] = useState(""); const [editModuleSortOrder, setEditModuleSortOrder] = useState(""); const [editModuleIcon, setEditModuleIcon] = useState(""); const [editModuleIconUploadBusy, setEditModuleIconUploadBusy] = useState(false); const [savingModuleEdit, setSavingModuleEdit] = useState(false); const [deletingModule, setDeletingModule] = useState(null); const [deletingModuleInFlight, setDeletingModuleInFlight] = useState(false); const [activeTab, setActiveTab] = useState<"modules" | "practice">("modules"); const [practiceFilter, setPracticeFilter] = useState("All"); const [practices, setPractices] = useState([]); const [practicesLoading, setPracticesLoading] = useState(false); const [practicesLoadError, setPracticesLoadError] = useState( null, ); const [publishStatusPracticeId, setPublishStatusPracticeId] = useState< number | null >(null); const openEditModule = (module: TopLevelCourseModuleItem) => { setEditingModule(module); setEditModuleName(module.name ?? ""); setEditModuleSortOrder(String(module.sort_order ?? 0)); setEditModuleIcon(module.icon?.trim() ?? ""); setEditModuleIconUploadBusy(false); }; const closeEditModule = () => { if (savingModuleEdit || editModuleIconUploadBusy) return; setEditingModule(null); setEditModuleIconUploadBusy(false); }; const loadPage = useCallback(async () => { if (!Number.isFinite(programId) || programId < 1) { setError("Invalid program"); setCourse(null); setModules([]); setLoading(false); return; } if (!Number.isFinite(courseIdNum) || courseIdNum < 1) { setError("Invalid course"); setCourse(null); setModules([]); setLoading(false); return; } setLoading(true); setError(null); try { const [courseOutcome, modulesOutcome] = await Promise.allSettled([ getProgramCourses(programId, { limit: 200, offset: 0 }), getTopLevelCourseModules(courseIdNum, { limit: 100, offset: 0 }), ]); if (courseOutcome.status === "fulfilled") { const raw = courseOutcome.value.data?.data?.courses; const list = Array.isArray(raw) ? raw : []; const found = list.find((c) => c.id === courseIdNum) ?? null; setCourse(found); if (!found) { setError("Course not found in this program"); } } else { console.error(courseOutcome.reason); setCourse(null); setError("Failed to load course"); } if (modulesOutcome.status === "fulfilled") { const raw = modulesOutcome.value.data?.data?.modules; const list = Array.isArray(raw) ? raw : []; const refreshed = await Promise.all( list.map(async (module) => { const icon = module.icon?.trim() ?? ""; if (!icon) return module; try { if (isSignedMinioUrl(icon)) { const refreshedRes = await refreshFileUrl(icon); const refreshedUrl = refreshedRes.data?.data?.url?.trim(); if (refreshedUrl) { return { ...module, icon: refreshedUrl }; } return module; } if (isLikelyImageUrl(icon)) return module; const resolved = await resolveFileUrl(icon); const freshUrl = resolved.data?.data?.url?.trim(); if (!freshUrl) return module; return { ...module, icon: freshUrl }; } catch { return module; } }), ); const sorted = [...refreshed].sort( (a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0), ); setModules(sorted); } else { console.error(modulesOutcome.reason); setModules([]); toast.error("Could not load modules", { description: "Check your connection or try again.", }); } } catch (e) { console.error(e); setError("Failed to load course"); setCourse(null); setModules([]); toast.error("Could not load course", { description: "Check your connection or try again.", }); } finally { setLoading(false); } }, [programId, courseIdNum]); useEffect(() => { void loadPage(); }, [loadPage]); const loadCoursePractices = useCallback(async () => { if (!Number.isFinite(courseIdNum) || courseIdNum < 1) { setPractices([]); setPracticesLoadError(null); setPracticesLoading(false); return; } setPracticesLoading(true); setPracticesLoadError(null); try { const res = await getPracticesByParentCourse(courseIdNum, { limit: 100, offset: 0, }); setPractices(unwrapPracticesList(res)); } catch { setPractices([]); setPracticesLoadError("Failed to load practices. Please try again."); } finally { setPracticesLoading(false); } }, [courseIdNum]); useEffect(() => { if (activeTab !== "practice") return; void loadCoursePractices(); }, [activeTab, loadCoursePractices]); const filteredPractices = useMemo(() => { if (practiceFilter === "Published") { return practices.filter(isPracticePublished); } if (practiceFilter === "Draft") { return practices.filter(isPracticeDraft); } if (practiceFilter === "Archived") { return []; } return practices; }, [practices, practiceFilter]); const handlePublishPractice = async (practiceId: number) => { setPublishStatusPracticeId(practiceId); try { await publishParentLinkedPractice(practiceId); setPractices((prev) => prev.map((p) => p.id === practiceId ? { ...p, publish_status: "PUBLISHED" } : p, ), ); toast.success("Practice published"); } catch (e: unknown) { console.error(e); const msg = (e as { response?: { data?: { message?: string } } })?.response?.data ?.message ?? "Failed to publish practice"; toast.error(msg); } finally { setPublishStatusPracticeId(null); } }; const handleSavePracticeAsDraft = async (practiceId: number) => { setPublishStatusPracticeId(practiceId); try { await updateParentLinkedPractice(practiceId, { publish_status: "DRAFT", }); setPractices((prev) => prev.map((p) => p.id === practiceId ? { ...p, publish_status: "DRAFT" } : p, ), ); toast.success("Practice saved as draft"); } catch (e: unknown) { console.error(e); const msg = (e as { response?: { data?: { message?: string } } })?.response?.data ?.message ?? "Failed to save practice as draft"; toast.error(msg); } finally { setPublishStatusPracticeId(null); } }; const handleSaveModuleEdit = async () => { if (!editingModule) return; const name = editModuleName.trim(); if (!name) { toast.error("Module name is required"); return; } const sortOrderRaw = editModuleSortOrder.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; } setSavingModuleEdit(true); try { await updateTopLevelCourseModule(editingModule.id, { name, description: editingModule.description?.trim() ?? "", icon: editModuleIcon.trim(), sort_order, }); toast.success("Module updated"); setEditModuleIconUploadBusy(false); setEditingModule(null); await loadPage(); } catch (e: unknown) { console.error(e); const msg = (e as { response?: { data?: { message?: string } } })?.response?.data ?.message ?? "Failed to update module"; toast.error(msg); } finally { setSavingModuleEdit(false); } }; const handleConfirmDeleteModule = async () => { if (!deletingModule) return; setDeletingModuleInFlight(true); try { await deleteTopLevelCourseModule(deletingModule.id); toast.success("Module deleted"); setDeletingModule(null); await loadPage(); } catch (e: unknown) { console.error(e); const msg = (e as { response?: { data?: { message?: string } } })?.response?.data ?.message ?? "Failed to delete module"; toast.error(msg); } finally { setDeletingModuleInFlight(false); } }; const displayTitle = course?.name?.trim() || courseIdParam || "Course"; const displayDescription = course?.description?.trim() || (!loading && !course ? "This course could not be loaded." : !course?.description?.trim() && course ? "—" : ""); return (
{/* Header Navigation */}
Back to Courses
{loading ? (
) : error && !course ? (

{error}

) : ( <>

{displayTitle}

{displayDescription}

setIsAddModuleOpen(false)} courseId={courseIdNum} onCreated={() => loadPage()} /> { if (!open && savingModuleEdit) return; if (!open && editModuleIconUploadBusy) return; if (!open) closeEditModule(); }} > Edit module Update name, sort order, and icon (upload or URL).
setEditModuleName(e.target.value)} className="rounded-xl" placeholder="e.g. Grammar basics" disabled={savingModuleEdit} />
setEditModuleSortOrder(e.target.value)} className="rounded-xl" placeholder="e.g. 5" disabled={savingModuleEdit || editModuleIconUploadBusy} />

Lower numbers appear first when modules are listed.

{activeTab === "modules" ? ( modules.length === 0 ? (

No modules in this course yet

Add a module to organize lessons and practices for this course.

) : (
{modules.map((module, index) => { const iconSrc = module.icon?.trim() ?? ""; return (

{module.name}

{module.description?.trim() ? module.description : "—"}

); })}
) ) : (
STATUS:
{["All", "Published", "Draft", "Archived"].map((label) => ( ))}
{practicesLoading ? (
Loading practices…
) : practicesLoadError ? (
{practicesLoadError}
) : filteredPractices.length > 0 ? (
{filteredPractices.map((practice) => ( navigate(`/content/practices?type=course&id=${courseIdNum}`) } onPublish={() => void handlePublishPractice(practice.id)} onSaveAsDraft={() => void handleSavePracticeAsDraft(practice.id) } /> ))}
) : (

{practices.length === 0 ? "No practices for this course yet" : "No practices match this filter"}

{practices.length === 0 ? "Add a course-level practice to give learners exercises attached to this course." : "Try another status filter or add a new practice."}

{practices.length === 0 ? ( ) : null}
)}
)} {deletingModule && (

Delete module

Are you sure you want to delete{" "} {deletingModule.name} ? This cannot be undone. Related content may be affected depending on your backend.

)} )}
); }