import { useCallback, useEffect, useMemo, useState } from "react" import { Link, useLocation, useNavigate, useParams } from "react-router-dom" import { ArrowLeft, BookOpen, Eye, FileText, Plus, Search, Trophy, Video } from "lucide-react" import { toast } from "sonner" import { Card } from "../../components/ui/card" import { Button } from "../../components/ui/button" import { Badge } from "../../components/ui/badge" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../components/ui/dialog" import { Input } from "../../components/ui/input" import { Select } from "../../components/ui/select" import { SpinnerIcon } from "../../components/ui/spinner-icon" import { Textarea } from "../../components/ui/textarea" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table" import { cn } from "../../lib/utils" import { getLessonsBySubModule, getQuestionSetsByOwner, getSubModuleLessonById, resolveSubModuleForCourse, getVideosBySubModule, softDeleteSubModuleLesson, updateSubModuleLesson, } from "../../api/courses.api" import type { QuestionSet, QuestionSetStatus, SubCourse, SubCourseVideo, SubModuleLesson, SubModuleLessonDetail, } from "../../types/course.types" type ContentTab = "lessons" | "practices" | "capstones" | "videos" function formatTableDate(dateStr: string) { return new Date(dateStr).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", }) } function formatVideoDuration(seconds: number) { if (!Number.isFinite(seconds) || seconds < 0) return "—" const m = Math.floor(seconds / 60) const s = Math.floor(seconds % 60) return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s` } function getRelativeTime(dateStr: string) { const now = new Date() const date = new Date(dateStr) const diffMs = now.getTime() - date.getTime() const diffMins = Math.floor(diffMs / 60000) const diffHours = Math.floor(diffMs / 3600000) const diffDays = Math.floor(diffMs / 86400000) if (diffMins < 1) return "Just now" if (diffMins < 60) return `${diffMins}m ago` if (diffHours < 24) return `${diffHours}h ago` if (diffDays < 7) return `${diffDays}d ago` return formatTableDate(dateStr) } function normalizeSetType(set: QuestionSet) { return String(set.set_type ?? "").toUpperCase() } function partitionSets(sets: QuestionSet[]) { const practices: QuestionSet[] = [] const capstones: QuestionSet[] = [] for (const s of sets) { const t = normalizeSetType(s) if (t === "PRACTICE") practices.push(s) else capstones.push(s) } return { practices, capstones } } type LessonActiveFilter = "all" | "active" | "inactive" function textMatchesQuery(text: string | null | undefined, q: string) { const needle = q.trim().toLowerCase() if (!needle) return true return (text ?? "").toLowerCase().includes(needle) } function filterQuestionSets(items: QuestionSet[], search: string, statusFilter: "all" | QuestionSetStatus) { return items.filter((item) => { if (statusFilter !== "all" && item.status !== statusFilter) return false const needle = search.trim() if (!needle) return true return textMatchesQuery(item.title, needle) || textMatchesQuery(item.description, needle) }) } function filterLessons(items: SubModuleLesson[], search: string, activeFilter: LessonActiveFilter) { return items.filter((lesson) => { if (activeFilter === "active" && !lesson.is_active) return false if (activeFilter === "inactive" && lesson.is_active) return false const needle = search.trim() if (!needle) return true return textMatchesQuery(lesson.title, needle) || textMatchesQuery(lesson.description, needle) }) } function lessonStatusBadge(isActive: boolean) { return isActive ? ( Active ) : ( Inactive ) } export function HumanLanguageSubModulePage() { const { categoryId, courseId, subModuleId } = useParams<{ categoryId: string courseId: string subModuleId: string }>() const navigate = useNavigate() const location = useLocation() const isHumanLanguageRoute = location.pathname.includes("/content/human-language/") const [subCourse, setSubCourse] = useState(null) const [loadingMeta, setLoadingMeta] = useState(true) const [metaError, setMetaError] = useState(null) const [activeTab, setActiveTab] = useState("practices") const [sets, setSets] = useState([]) const [setsLoading, setSetsLoading] = useState(false) const [lessons, setLessons] = useState([]) const [lessonsLoading, setLessonsLoading] = useState(false) const [lessonDetailOpen, setLessonDetailOpen] = useState(false) const [lessonDetailLoading, setLessonDetailLoading] = useState(false) const [lessonDetail, setLessonDetail] = useState(null) const [lessonEditMode, setLessonEditMode] = useState(false) const [lessonUpdateSaving, setLessonUpdateSaving] = useState(false) const [lessonSoftDeleteSaving, setLessonSoftDeleteSaving] = useState(false) const [lessonSoftDeleteConfirmOpen, setLessonSoftDeleteConfirmOpen] = useState(false) const [editLessonTitle, setEditLessonTitle] = useState("") const [editLessonDescription, setEditLessonDescription] = useState("") const [editLessonThumbnail, setEditLessonThumbnail] = useState("") const [editLessonTeachingText, setEditLessonTeachingText] = useState("") const [editLessonTeachingImageUrl, setEditLessonTeachingImageUrl] = useState("") const [editLessonTeachingAudioUrl, setEditLessonTeachingAudioUrl] = useState("") const [editLessonTeachingVideoUrl, setEditLessonTeachingVideoUrl] = useState("") const [editLessonDisplayOrder, setEditLessonDisplayOrder] = useState(0) const [editLessonIsActive, setEditLessonIsActive] = useState(true) const [videos, setVideos] = useState([]) const [videosLoading, setVideosLoading] = useState(false) const [practiceSearch, setPracticeSearch] = useState("") const [practiceStatusFilter, setPracticeStatusFilter] = useState<"all" | QuestionSetStatus>("all") const [capstoneSearch, setCapstoneSearch] = useState("") const [capstoneStatusFilter, setCapstoneStatusFilter] = useState<"all" | QuestionSetStatus>("all") const [lessonSearch, setLessonSearch] = useState("") const [lessonActiveFilter, setLessonActiveFilter] = useState("all") const numericCourseId = Number(courseId) const numericSubModuleId = Number(subModuleId) useEffect(() => { const run = async () => { if (!subModuleId || !courseId || Number.isNaN(numericCourseId) || Number.isNaN(numericSubModuleId)) { setMetaError("Invalid route parameters") setLoadingMeta(false) return } setLoadingMeta(true) setMetaError(null) try { const found = (await resolveSubModuleForCourse(numericCourseId, numericSubModuleId)) ?? null setSubCourse(found) if (!found) setMetaError("Sub-module not found for this course") } catch (e) { console.error(e) setMetaError("Failed to load sub-module") setSubCourse(null) toast.error("Failed to load sub-module") } finally { setLoadingMeta(false) } } void run() }, [subModuleId, courseId, numericCourseId, numericSubModuleId]) const fetchSets = useCallback(async () => { if (!subModuleId || Number.isNaN(numericSubModuleId)) return setSetsLoading(true) try { const res = await getQuestionSetsByOwner("SUB_MODULE", numericSubModuleId) const raw = res.data?.data const list = Array.isArray(raw) ? raw : (raw as { question_sets?: QuestionSet[] })?.question_sets ?? [] setSets(list) } catch (e) { console.error(e) toast.error("Failed to load question sets") setSets([]) } finally { setSetsLoading(false) } }, [subModuleId, numericSubModuleId]) const fetchVideos = useCallback(async () => { if (!subModuleId || Number.isNaN(numericSubModuleId)) return setVideosLoading(true) try { const res = await getVideosBySubModule(numericSubModuleId) setVideos(res.data?.data?.videos ?? []) } catch (e) { console.error(e) toast.error("Failed to load videos") setVideos([]) } finally { setVideosLoading(false) } }, [subModuleId, numericSubModuleId]) const fetchLessons = useCallback(async () => { if (!subModuleId || Number.isNaN(numericSubModuleId)) return setLessonsLoading(true) try { const res = await getLessonsBySubModule(numericSubModuleId, { includeInactive: true }) const list = Array.isArray(res.data?.data) ? res.data.data : [] setLessons(list) } catch (e) { console.error(e) toast.error("Failed to load lessons") setLessons([]) } finally { setLessonsLoading(false) } }, [subModuleId, numericSubModuleId]) const openLessonDetail = useCallback(async (lessonId: number) => { setLessonDetailOpen(true) setLessonDetailLoading(true) setLessonEditMode(false) setLessonDetail(null) try { const res = await getSubModuleLessonById(lessonId) setLessonDetail(res.data?.data ?? null) } catch (e) { console.error(e) toast.error("Failed to load lesson detail") } finally { setLessonDetailLoading(false) } }, []) const startEditLesson = () => { if (!lessonDetail) return setEditLessonTitle(lessonDetail.title ?? "") setEditLessonDescription(lessonDetail.description ?? "") setEditLessonThumbnail(lessonDetail.thumbnail ?? "") setEditLessonTeachingText(lessonDetail.teaching_text ?? "") setEditLessonTeachingImageUrl(lessonDetail.teaching_image_url ?? "") setEditLessonTeachingAudioUrl(lessonDetail.teaching_audio_url ?? "") setEditLessonTeachingVideoUrl(lessonDetail.teaching_video_url ?? "") setEditLessonDisplayOrder(lessonDetail.display_order ?? 0) setEditLessonIsActive(lessonDetail.is_active) setLessonEditMode(true) } const handleUpdateLesson = async () => { if (!lessonDetail) return const title = editLessonTitle.trim() if (!title) { toast.error("Lesson title is required") return } setLessonUpdateSaving(true) try { const response = await updateSubModuleLesson(lessonDetail.id, { title, description: editLessonDescription.trim() || null, thumbnail: editLessonThumbnail.trim() || null, teaching_text: editLessonTeachingText.trim() || null, teaching_image_url: editLessonTeachingImageUrl.trim() || null, teaching_audio_url: editLessonTeachingAudioUrl.trim() || null, teaching_video_url: editLessonTeachingVideoUrl.trim() || null, display_order: Math.max(0, Number(editLessonDisplayOrder) || 0), is_active: editLessonIsActive, }) setLessonDetail(response.data?.data ?? null) setLessonEditMode(false) toast.success("Lesson updated") await fetchLessons() } catch (error) { console.error(error) toast.error("Failed to update lesson") } finally { setLessonUpdateSaving(false) } } const handleSoftDeleteLesson = async () => { if (!lessonDetail || lessonSoftDeleteSaving) return setLessonSoftDeleteSaving(true) try { const response = await softDeleteSubModuleLesson(lessonDetail.id) setLessonDetail(response.data?.data ?? { ...lessonDetail, is_active: false }) setLessonEditMode(false) setLessonSoftDeleteConfirmOpen(false) toast.success("Lesson soft deleted") await fetchLessons() } catch (error) { console.error(error) toast.error("Failed to soft delete lesson") } finally { setLessonSoftDeleteSaving(false) } } useEffect(() => { void fetchSets() }, [fetchSets]) useEffect(() => { if (activeTab === "lessons") void fetchLessons() }, [activeTab, fetchLessons]) useEffect(() => { if (activeTab === "videos") void fetchVideos() }, [activeTab, fetchVideos]) const { practices, capstones } = useMemo(() => partitionSets(sets), [sets]) const filteredPractices = useMemo( () => filterQuestionSets(practices, practiceSearch, practiceStatusFilter), [practices, practiceSearch, practiceStatusFilter], ) const filteredCapstones = useMemo( () => filterQuestionSets(capstones, capstoneSearch, capstoneStatusFilter), [capstones, capstoneSearch, capstoneStatusFilter], ) const filteredLessons = useMemo( () => filterLessons(lessons, lessonSearch, lessonActiveFilter), [lessons, lessonSearch, lessonActiveFilter], ) const subModuleLabel = useMemo(() => { const rawTitle = subCourse?.title?.trim() if (!rawTitle) return "Sub-module" const cleaned = rawTitle.replace(/^module\s*[-:]?\s*/i, "") const numericMatch = cleaned.match(/\d+(?:\.\d+)+|\d+/) if (!numericMatch) return "Sub-module" return `Sub-module ${numericMatch[0]}` }, [subCourse?.title]) const basePath = isHumanLanguageRoute ? `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}` : `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}` const courseStructurePath = `/content/category/${categoryId}/courses/${courseId}/sub-modules` const backHref = isHumanLanguageRoute ? "/content/human-language" : courseStructurePath const backLabel = isHumanLanguageRoute ? "Back to Human Language" : "Back to course structure" const goQuestions = (questionSetId: number) => { navigate(`${basePath}/practices/${questionSetId}/questions`) } const questionSetStatusBadge = (status: string) => { const cfg: Record = { PUBLISHED: { cls: "border-emerald-200 bg-emerald-50 text-emerald-700", label: "Published" }, DRAFT: { cls: "border-grayScale-200 bg-grayScale-100 text-grayScale-600", label: "Draft" }, ARCHIVED: { cls: "border-amber-200 bg-amber-50 text-amber-800", label: "Archived" }, } const c = cfg[status] ?? cfg.DRAFT return ( {c.label} ) } const setTypeBadge = (set: QuestionSet) => ( {normalizeSetType(set)} ) const renderQuestionSetTable = ( filtered: QuestionSet[], source: QuestionSet[], emptyLabel: string, addHref: string, addLabel: string, noMatchLabel: string, ) => { const colCount = 8 if (setsLoading) { return (
TITLE TYPE STATUS DESCRIPTION PERSONA SHUFFLE CREATED DETAILS
Loading question sets…
) } if (source.length === 0) { return (

{emptyLabel}

) } return (
TITLE TYPE STATUS DESCRIPTION PERSONA SHUFFLE CREATED DETAILS {filtered.length === 0 ? (

{noMatchLabel}

Try adjusting search or status.

) : ( filtered.map((item) => ( goQuestions(item.id)} onKeyDown={(ev) => { if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault() goQuestions(item.id) } }} >

{item.title}

#{item.id}

{setTypeBadge(item)} {questionSetStatusBadge(item.status)}

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

{item.persona?.trim() ? item.persona : "—"}

{item.shuffle_questions ? "Yes" : "No"}

{formatTableDate(item.created_at)}

{getRelativeTime(item.created_at)}

)) )}
) } const renderLessonsTable = (filtered: SubModuleLesson[], source: SubModuleLesson[]) => { const colCount = 7 const sorted = filtered.slice().sort((a, b) => a.display_order - b.display_order || a.id - b.id) if (lessonsLoading) { return (
LESSON ORDER STATUS DESCRIPTION THUMBNAIL CREATED DETAILS
Loading lessons…
) } if (source.length === 0) { return (

No lessons yet

) } return (
LESSON ORDER STATUS DESCRIPTION THUMBNAIL CREATED DETAILS {sorted.length === 0 ? (

No lessons match your search or status filter.

Try adjusting search or status.

) : ( sorted.map((lesson) => ( void openLessonDetail(lesson.id)} onKeyDown={(ev) => { if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault() void openLessonDetail(lesson.id) } }} >

{lesson.title}

#{lesson.id}

{lesson.display_order} {lessonStatusBadge(lesson.is_active)}

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

{lesson.thumbnail?.trim() ? ( Yes ) : ( None )}

{formatTableDate(lesson.created_at)}

{getRelativeTime(lesson.created_at)}

)) )}
) } if (loadingMeta) { return (

Loading sub-module…

) } if (metaError && !subCourse) { return (
{backLabel}

{metaError}

) } return (
{backLabel}

{subModuleLabel}

{subCourse?.cefr_level || subCourse?.level ? ( {subCourse.cefr_level ?? subCourse.level} ) : null}

Practices come from question sets (`PRACTICE`) and intro videos are listed separately as sub-module-level media.

{( [ ["practices", "Practices", FileText], ["lessons", "Lessons", BookOpen], ["capstones", "Capstones", Trophy], ["videos", "Intro videos", Video], ] as const ).map(([id, label, Icon]) => ( ))}
{activeTab === "practices" ? (
setPracticeSearch(e.target.value)} aria-label="Search practices" />
{renderQuestionSetTable( filteredPractices, practices, "No practices yet", `${basePath}/add-practice`, "Create a practice", "No practices match your search or status filter.", )}
) : null} {activeTab === "lessons" ? (
setLessonSearch(e.target.value)} aria-label="Search lessons" />
{renderLessonsTable(filteredLessons, lessons)}
) : null} {activeTab === "capstones" ? (
setCapstoneSearch(e.target.value)} aria-label="Search capstones" />
{renderQuestionSetTable( filteredCapstones, capstones, "No capstone-style sets yet (e.g. EXAM). Add content via your usual authoring flow or API.", `${basePath}/add-practice`, "Add question set (practice flow)", "No capstones match your search or status filter.", )}
) : null} {activeTab === "videos" ? ( videosLoading ? (
TITLE DESCRIPTION DURATION PUBLISHED ACTIVE THUMBNAIL LINK
Loading videos…
) : videos.length === 0 ? (
No intro videos on this sub-module yet.
) : (
TITLE DESCRIPTION DURATION PUBLISHED ACTIVE THUMBNAIL LINK {videos.map((v) => (

{v.title}

#{v.id}

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

{formatVideoDuration(v.duration)} {v.is_published ? "Yes" : "No"} {v.is_active ? "Yes" : "No"} {v.thumbnail?.trim() ? ( Yes ) : ( None )} {v.video_url ? ( ) : ( )}
))}
) ) : null} { if (lessonUpdateSaving || lessonSoftDeleteSaving) return setLessonDetailOpen(open) if (!open) { setLessonDetail(null) setLessonEditMode(false) setLessonSoftDeleteConfirmOpen(false) } }} > Lesson detail Loaded from `GET /course-management/sub-module-lessons/:lessonId`. {lessonDetailLoading ? (

Loading lesson detail…

) : !lessonDetail ? (
Lesson detail unavailable.
) : lessonEditMode ? (
setEditLessonTitle(event.target.value)} placeholder="Lesson title" disabled={lessonUpdateSaving} />