import { useCallback, useEffect, 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 { Textarea } from "../../components/ui/textarea";
import { cn } from "../../lib/utils";
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
import alertSrc from "../../assets/Alert.svg";
import {
deleteTopLevelCourseModule,
getProgramCourses,
getTopLevelCourseModules,
updateTopLevelCourseModule,
} from "../../api/courses.api";
import { resolveFileUrl } from "../../api/files.api";
import type {
ProgramCourseListItem,
TopLevelCourseModuleItem,
} from "../../types/course.types";
import { AddModuleModal } from "./components/AddModuleModal";
import { ModuleIconUploadField } from "./components/ModuleIconUploadField";
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 isSignedS3Url(src: string): boolean {
const trimmed = src.trim();
if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) {
return false;
}
try {
const url = new URL(trimmed);
return url.searchParams.has("X-Amz-Signature");
} catch {
return false;
}
}
function extractObjectKeyFromUrl(src: string): string {
try {
const url = new URL(src);
return url.pathname.replace(/^\/+/, "").trim();
} catch {
return "";
}
}
/** 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 [editModuleDescription, setEditModuleDescription] = 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 openEditModule = (module: TopLevelCourseModuleItem) => {
setEditingModule(module);
setEditModuleName(module.name ?? "");
setEditModuleDescription(module.description ?? "");
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 || !isSignedS3Url(icon)) return module;
const objectKey = extractObjectKeyFromUrl(icon);
if (!objectKey) return module;
try {
const resolved = await resolveFileUrl(objectKey);
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 handleSaveModuleEdit = async () => {
if (!editingModule) return;
const name = editModuleName.trim();
if (!name) {
toast.error("Module name is required");
return;
}
setSavingModuleEdit(true);
try {
await updateTopLevelCourseModule(editingModule.id, {
name,
description: editModuleDescription.trim(),
icon: editModuleIcon.trim(),
});
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 */}
{loading ? (
) : error && !course ? (
{error}
) : (
<>
{/* Hero Section */}
{displayTitle}
{displayDescription}
setIsAddModuleOpen(false)}
courseId={courseIdNum}
onCreated={() => loadPage()}
/>
{modules.length === 0 ? (
No modules in this course yet
Add modules when your workflow is connected, or create them via
the API.
) : (
{modules.map((module, index) => {
const iconSrc = module.icon?.trim() ?? "";
return (
{module.name}
{module.description?.trim() ? module.description : "—"}
);
})}
)}
{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.
)}
>
)}
);
}