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 */}
{loading ? (
) : error && !course ? (
{error}
void loadPage()}
>
Try again
) : (
<>
{displayTitle}
{displayDescription}
navigate(
`/new-content/learn-english/${programIdParam}/courses/add-practice?backTo=modules&courseId=${courseIdParam}`,
)
}
>
Add Practice
setIsAddModuleOpen(true)}
>
Add Module
setActiveTab("modules")}
className={cn(
"pb-4 text-[16px] font-medium transition-all relative",
activeTab === "modules"
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
: "text-grayScale-400 hover:text-grayScale-600",
)}
>
Modules
setActiveTab("practice")}
className={cn(
"pb-4 text-[16px] font-medium transition-all relative",
activeTab === "practice"
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
: "text-grayScale-400 hover:text-grayScale-600",
)}
>
Practice
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).
Cancel
void handleSaveModuleEdit()}
>
{savingModuleEdit ? "Saving…" : "Save changes"}
{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 (
openEditModule(module)}
>
setDeletingModule(module)}
>
{module.name}
{module.description?.trim()
? module.description
: "—"}
navigate(
`/new-content/learn-english/${programIdParam}/courses/${courseIdParam}/modules/${module.id}`,
{
state: {
moduleName: module.name,
moduleDescription:
module.description?.trim() ?? "",
},
},
)
}
>
View Detail
);
})}
)
) : (
STATUS:
{["All", "Published", "Draft", "Archived"].map((label) => (
setPracticeFilter(label)}
className={cn(
"h-9 rounded-full px-5 text-[13px] font-bold transition-all",
practiceFilter === label
? "bg-brand-500 text-white shadow-md shadow-brand-500/20"
: "bg-[#F1F5F9] text-grayScale-500 hover:bg-grayScale-100",
)}
>
{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 ? (
navigate(
`/new-content/learn-english/${programIdParam}/courses/add-practice?backTo=modules&courseId=${courseIdParam}`,
)
}
>
Add Practice
) : null}
)}
)}
{deletingModule && (
Delete module
!deletingModuleInFlight && setDeletingModule(null)
}
disabled={deletingModuleInFlight}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 disabled:pointer-events-none disabled:opacity-50"
>
Are you sure you want to delete{" "}
{deletingModule.name}
? This cannot be undone. Related content may be affected
depending on your backend.
setDeletingModule(null)}
disabled={deletingModuleInFlight}
className="w-full sm:w-auto"
>
Cancel
void handleConfirmDeleteModule()}
>
{deletingModuleInFlight ? "Deleting…" : "Delete"}
)}
>
)}
);
}