Yimaru-Admin/src/pages/content-management/HumanLanguageHierarchyPage.tsx
Yared Yemane 92a2fab833 feat(admin): dynamic content flows, cleaner UI copy, and table pagination
Add Learn English practice and question-type builder integrations with dynamic schema slots and HTML table editor. Remove API path labels from admin pages and standardize table page-size options to 5, 10, 30, 50, and 100.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 12:34:39 -07:00

1282 lines
56 KiB
TypeScript

import { type Dispatch, type SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Link } from "react-router-dom"
import { BookOpen, ChevronDown, ChevronRight, FolderTree, Languages, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
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 {
createModule,
deleteModule,
getCourseHierarchyByCourseId,
getHumanLanguageHierarchy,
getPracticesByLevel,
updateModule,
} from "../../api/courses.api"
import { uploadImageFile } from "../../api/files.api"
import type { CourseHierarchyRow, HumanLanguageHierarchyFlatRow, Practice } from "../../types/course.types"
type IdFilterValue = number | "ALL"
type LevelFilterValue = number | "ALL"
type SubCategoryOption = { id: number; name: string; category_id: number; category_name: string }
type CourseOption = { id: number; title: string }
type SubModuleNode = { id: number; title: string; display_order: number | null }
type ModuleNode = { id: number; title: string; icon_url?: string | null; sub_modules: SubModuleNode[] }
type LevelNode = { id: number; cefr_level: string; title: string; modules: ModuleNode[] }
type CourseTreeNode = { course_id: number; course_title: string; levels: LevelNode[] }
type DeleteModuleTarget = {
courseId: number
moduleId: number
moduleTitle: string
levelTitle: string
moduleKey: string
}
type EditModuleTarget = {
courseId: number
moduleId: number
moduleKey: string
levelKey: string
}
type HierarchyReturnState = {
selectedSubCategoryId: IdFilterValue
selectedCourseId: IdFilterValue
selectedLevelId: LevelFilterValue
expandedCourses: string[]
expandedLevels: string[]
expandedModules: string[]
scrollY: number
}
const HUMAN_LANGUAGE_RETURN_STATE_KEY = "humanLanguageHierarchyReturnState"
const CEFR_ORDER = ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3"]
const textCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" })
const setHas = (set: Set<string>, key: string) => set.has(key)
const toggleSetValue = (setState: Dispatch<SetStateAction<Set<string>>>, key: string) => {
setState((previous) => {
const next = new Set(previous)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
function cefrSortValue(level: string) {
const idx = CEFR_ORDER.indexOf(level.trim().toUpperCase())
return idx === -1 ? Number.MAX_SAFE_INTEGER : idx
}
function toSubCategoryOptions(rows: HumanLanguageHierarchyFlatRow[]): SubCategoryOption[] {
const map = new Map<number, SubCategoryOption>()
rows.forEach((row) => {
if (!row.sub_category_id || map.has(row.sub_category_id)) return
map.set(row.sub_category_id, {
id: row.sub_category_id,
name: row.sub_category_name ?? "Unnamed sub-category",
category_id: row.category_id,
category_name: row.category_name,
})
})
return Array.from(map.values()).sort((a, b) => textCollator.compare(a.name, b.name) || a.id - b.id)
}
function toCourseOptions(rows: HumanLanguageHierarchyFlatRow[], subCategoryId: number): CourseOption[] {
const map = new Map<number, CourseOption>()
rows
.filter((row) => row.sub_category_id === subCategoryId && !!row.course_id)
.forEach((row) => {
if (!row.course_id || map.has(row.course_id)) return
map.set(row.course_id, { id: row.course_id, title: row.course_title ?? `Course ${row.course_id}` })
})
return Array.from(map.values()).sort((a, b) => textCollator.compare(a.title, b.title) || a.id - b.id)
}
function toLevelNodes(rows: CourseHierarchyRow[]): LevelNode[] {
const levelMap = new Map<number, { cefr_level: string; title: string; modules: Map<number, ModuleNode> }>()
rows.forEach((row) => {
if (!row.level_id) return
const levelId = Number(row.level_id)
const cefr = (row.cefr_level ?? "").trim().toUpperCase()
if (!levelMap.has(levelId)) {
levelMap.set(levelId, {
cefr_level: cefr,
title: row.level_title?.trim() || cefr || `Level ${levelId}`,
modules: new Map(),
})
}
if (!row.module_id) return
const moduleId = Number(row.module_id)
const levelNode = levelMap.get(levelId)!
if (!levelNode.modules.has(moduleId)) {
levelNode.modules.set(moduleId, {
id: moduleId,
title: row.module_title?.trim() || `Module ${moduleId}`,
icon_url: row.module_icon_url ?? null,
sub_modules: [],
})
} else if (row.module_icon_url && !levelNode.modules.get(moduleId)?.icon_url) {
levelNode.modules.set(moduleId, {
...levelNode.modules.get(moduleId)!,
icon_url: row.module_icon_url,
})
}
if (!row.sub_module_id) return
const moduleNode = levelNode.modules.get(moduleId)!
const subModuleId = Number(row.sub_module_id)
if (moduleNode.sub_modules.some((item) => item.id === subModuleId)) return
moduleNode.sub_modules.push({
id: subModuleId,
title: row.sub_module_title?.trim() || `Sub-module ${subModuleId}`,
display_order: row.sub_module_display_order ?? null,
})
})
return Array.from(levelMap.entries())
.map(([id, level]) => ({
id,
cefr_level: level.cefr_level,
title: level.title,
modules: Array.from(level.modules.values())
.map((module) => ({
...module,
sub_modules: [...module.sub_modules].sort((a, b) => {
const ao = a.display_order ?? Number.MAX_SAFE_INTEGER
const bo = b.display_order ?? Number.MAX_SAFE_INTEGER
return ao - bo || textCollator.compare(a.title, b.title) || a.id - b.id
}),
}))
.sort((a, b) => textCollator.compare(a.title, b.title) || a.id - b.id),
}))
.sort((a, b) => {
const byCefr = cefrSortValue(a.cefr_level) - cefrSortValue(b.cefr_level)
return byCefr || textCollator.compare(a.title, b.title) || a.id - b.id
})
}
function getNextDefaultModuleName(level: LevelNode) {
const usedMinorNumbers = new Set<number>()
level.modules.forEach((module) => {
const match = module.title.trim().match(/^module-\s*1\.(\d+)$/i)
if (!match) return
const minor = Number(match[1])
if (Number.isFinite(minor) && minor > 0) {
usedMinorNumbers.add(minor)
}
})
let nextMinor = 1
while (usedMinorNumbers.has(nextMinor)) {
nextMinor += 1
}
return `Module-1.${nextMinor}`
}
export function HumanLanguageHierarchyPage() {
const readPersistedReturnState = (): HierarchyReturnState | null => {
try {
const raw = window.sessionStorage.getItem(HUMAN_LANGUAGE_RETURN_STATE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as Partial<HierarchyReturnState>
return {
selectedSubCategoryId: parsed.selectedSubCategoryId ?? "ALL",
selectedCourseId: parsed.selectedCourseId ?? "ALL",
selectedLevelId: parsed.selectedLevelId ?? "ALL",
expandedCourses: Array.isArray(parsed.expandedCourses) ? parsed.expandedCourses : [],
expandedLevels: Array.isArray(parsed.expandedLevels) ? parsed.expandedLevels : [],
expandedModules: Array.isArray(parsed.expandedModules) ? parsed.expandedModules : [],
scrollY: Number.isFinite(parsed.scrollY) ? Number(parsed.scrollY) : 0,
}
} catch {
return null
}
}
const persistedReturnStateRef = useRef<HierarchyReturnState | null>(readPersistedReturnState())
const skipInitialSubCategoryResetRef = useRef(!!persistedReturnStateRef.current)
const skipFilterValidationRef = useRef(!!persistedReturnStateRef.current)
const pendingRestoreScrollRef = useRef<number | null>(persistedReturnStateRef.current?.scrollY ?? null)
const [hierarchyRows, setHierarchyRows] = useState<HumanLanguageHierarchyFlatRow[]>([])
const [hierarchyLoading, setHierarchyLoading] = useState(true)
const [hierarchyError, setHierarchyError] = useState<string | null>(null)
const [selectedSubCategoryId, setSelectedSubCategoryId] = useState<IdFilterValue>(
persistedReturnStateRef.current?.selectedSubCategoryId ?? "ALL",
)
const [selectedCourseId, setSelectedCourseId] = useState<IdFilterValue>(
persistedReturnStateRef.current?.selectedCourseId ?? "ALL",
)
const [selectedLevelId, setSelectedLevelId] = useState<LevelFilterValue>(
persistedReturnStateRef.current?.selectedLevelId ?? "ALL",
)
const [courseRowsByCourseId, setCourseRowsByCourseId] = useState<Record<number, CourseHierarchyRow[]>>({})
const [courseHierarchyLoading, setCourseHierarchyLoading] = useState(false)
const [courseHierarchyError, setCourseHierarchyError] = useState<string | null>(null)
const [levelPracticesByLevelId, setLevelPracticesByLevelId] = useState<Record<number, Practice[]>>({})
const [expandedCourses, setExpandedCourses] = useState<Set<string>>(
() => new Set(persistedReturnStateRef.current?.expandedCourses ?? []),
)
const [expandedLevels, setExpandedLevels] = useState<Set<string>>(
() => new Set(persistedReturnStateRef.current?.expandedLevels ?? []),
)
const [expandedModules, setExpandedModules] = useState<Set<string>>(
() => new Set(persistedReturnStateRef.current?.expandedModules ?? []),
)
const [createModuleOpen, setCreateModuleOpen] = useState(false)
const [createModuleSaving, setCreateModuleSaving] = useState(false)
const [createModuleCourseId, setCreateModuleCourseId] = useState<number | null>(null)
const [createModuleLevelId, setCreateModuleLevelId] = useState<number | null>(null)
const [createModuleLevelKey, setCreateModuleLevelKey] = useState<string>("")
const [createModuleTitle, setCreateModuleTitle] = useState("")
const [createModuleUseDefaultNaming, setCreateModuleUseDefaultNaming] = useState(false)
const [createModuleDefaultTitle, setCreateModuleDefaultTitle] = useState("")
const [createModuleIconSource, setCreateModuleIconSource] = useState<"url" | "file">("url")
const [createModuleIconUrl, setCreateModuleIconUrl] = useState("")
const [createModuleIconFile, setCreateModuleIconFile] = useState<File | null>(null)
const [createModuleDisplayOrder, setCreateModuleDisplayOrder] = useState(0)
const [deleteModuleSavingId, setDeleteModuleSavingId] = useState<number | null>(null)
const [deleteModuleDialogOpen, setDeleteModuleDialogOpen] = useState(false)
const [deleteModuleTarget, setDeleteModuleTarget] = useState<DeleteModuleTarget | null>(null)
const [editModuleDialogOpen, setEditModuleDialogOpen] = useState(false)
const [editModuleSaving, setEditModuleSaving] = useState(false)
const [editModuleTarget, setEditModuleTarget] = useState<EditModuleTarget | null>(null)
const [editModuleTitle, setEditModuleTitle] = useState("")
const [editModuleDisplayOrder, setEditModuleDisplayOrder] = useState(0)
const [editModuleIconSource, setEditModuleIconSource] = useState<"url" | "file">("url")
const [editModuleIconUrl, setEditModuleIconUrl] = useState("")
const [editModuleOriginalIconUrl, setEditModuleOriginalIconUrl] = useState("")
const [editModuleIconFile, setEditModuleIconFile] = useState<File | null>(null)
const subCategoryOptions = useMemo(() => toSubCategoryOptions(hierarchyRows), [hierarchyRows])
const selectedSubCategory = useMemo(
() => (selectedSubCategoryId === "ALL" ? null : subCategoryOptions.find((item) => item.id === selectedSubCategoryId) ?? null),
[selectedSubCategoryId, subCategoryOptions],
)
const courseOptions = useMemo(
() => (selectedSubCategoryId === "ALL" ? [] : toCourseOptions(hierarchyRows, selectedSubCategoryId)),
[hierarchyRows, selectedSubCategoryId],
)
const selectedCourse = useMemo(
() => (selectedCourseId === "ALL" ? null : courseOptions.find((item) => item.id === selectedCourseId) ?? null),
[selectedCourseId, courseOptions],
)
const targetCourseIds = useMemo(() => {
if (selectedSubCategoryId === "ALL") return []
if (selectedCourseId !== "ALL") return [selectedCourseId]
return courseOptions.map((course) => course.id)
}, [selectedSubCategoryId, selectedCourseId, courseOptions])
const courseTrees = useMemo<CourseTreeNode[]>(() => {
const targetSet = new Set(targetCourseIds)
return courseOptions
.filter((course) => targetSet.has(course.id))
.map((course) => {
const levels = toLevelNodes(courseRowsByCourseId[course.id] ?? [])
return {
course_id: course.id,
course_title: course.title,
levels: selectedLevelId === "ALL" ? levels : levels.filter((level) => level.id === selectedLevelId),
}
})
}, [courseOptions, courseRowsByCourseId, targetCourseIds, selectedLevelId])
const fetchHumanLanguageHierarchy = useCallback(async () => {
setHierarchyLoading(true)
setHierarchyError(null)
try {
const res = await getHumanLanguageHierarchy()
setHierarchyRows(res.data?.data ?? [])
} catch (err) {
console.error(err)
setHierarchyRows([])
setHierarchyError("Could not load Human Language hierarchy")
toast.error("Failed to load Human Language hierarchy")
} finally {
setHierarchyLoading(false)
}
}, [])
const fetchHierarchiesForCourses = useCallback(async (courseIds: number[]) => {
if (courseIds.length === 0) {
setCourseRowsByCourseId({})
setLevelPracticesByLevelId({})
setCourseHierarchyError(null)
return
}
setCourseHierarchyLoading(true)
setCourseHierarchyError(null)
try {
const courseEntries = await Promise.all(
courseIds.map(async (courseId) => {
try {
const response = await getCourseHierarchyByCourseId(courseId)
return [courseId, response.data?.data ?? []] as const
} catch {
return [courseId, [] as CourseHierarchyRow[]] as const
}
}),
)
const nextRowsByCourseId = Object.fromEntries(courseEntries)
setCourseRowsByCourseId(nextRowsByCourseId)
const levelIds = Array.from(
new Set(
courseEntries
.flatMap((entry) => entry[1])
.map((row) => Number(row.level_id))
.filter((id) => Number.isFinite(id) && id > 0),
),
)
const levelPracticeEntries = await Promise.all(
levelIds.map(async (levelId) => {
try {
const practiceRes = await getPracticesByLevel(levelId)
return [levelId, (practiceRes.data?.data?.practices ?? []).filter((practice) => practice.is_active)] as const
} catch {
return [levelId, [] as Practice[]] as const
}
}),
)
setLevelPracticesByLevelId(Object.fromEntries(levelPracticeEntries))
} catch (err) {
console.error(err)
setCourseRowsByCourseId({})
setLevelPracticesByLevelId({})
setCourseHierarchyError("Could not load hierarchy for selected courses")
toast.error("Failed to load course hierarchy")
} finally {
setCourseHierarchyLoading(false)
}
}, [])
useEffect(() => {
void fetchHumanLanguageHierarchy()
}, [fetchHumanLanguageHierarchy])
useEffect(() => {
window.sessionStorage.removeItem(HUMAN_LANGUAGE_RETURN_STATE_KEY)
}, [])
useEffect(() => {
if (!hierarchyLoading) {
skipFilterValidationRef.current = false
}
}, [hierarchyLoading])
useEffect(() => {
if (skipInitialSubCategoryResetRef.current) {
skipInitialSubCategoryResetRef.current = false
return
}
if (selectedSubCategoryId === "ALL") {
setSelectedCourseId("ALL")
setSelectedLevelId("ALL")
setCourseRowsByCourseId({})
setLevelPracticesByLevelId({})
setExpandedCourses(new Set())
setExpandedLevels(new Set())
setExpandedModules(new Set())
return
}
setSelectedCourseId("ALL")
setSelectedLevelId("ALL")
}, [selectedSubCategoryId])
useEffect(() => {
if (selectedSubCategoryId === "ALL") return
void fetchHierarchiesForCourses(targetCourseIds)
}, [selectedSubCategoryId, targetCourseIds, fetchHierarchiesForCourses])
useEffect(() => {
if (skipFilterValidationRef.current) return
if (selectedSubCategoryId !== "ALL" && !subCategoryOptions.some((item) => item.id === selectedSubCategoryId)) {
setSelectedSubCategoryId("ALL")
}
}, [selectedSubCategoryId, subCategoryOptions])
useEffect(() => {
if (skipFilterValidationRef.current) return
if (selectedCourseId !== "ALL" && !courseOptions.some((item) => item.id === selectedCourseId)) {
setSelectedCourseId("ALL")
}
}, [selectedCourseId, courseOptions])
useEffect(() => {
if (skipFilterValidationRef.current) return
const levelIds = new Set(courseTrees.flatMap((course) => course.levels.map((level) => level.id)))
if (selectedLevelId !== "ALL" && !levelIds.has(selectedLevelId)) {
setSelectedLevelId("ALL")
}
}, [selectedLevelId, courseTrees])
useEffect(() => {
const courseKeys = new Set(courseTrees.map((course) => `course-${course.course_id}`))
const levelKeys = new Set(
courseTrees.flatMap((course) => course.levels.map((level) => `level-${course.course_id}-${level.id}`)),
)
const moduleKeys = new Set(
courseTrees.flatMap((course) =>
course.levels.flatMap((level) => level.modules.map((module) => `module-${course.course_id}-${level.id}-${module.id}`)),
),
)
setExpandedCourses((previous) => new Set([...previous].filter((key) => courseKeys.has(key))))
setExpandedLevels((previous) => new Set([...previous].filter((key) => levelKeys.has(key))))
setExpandedModules((previous) => new Set([...previous].filter((key) => moduleKeys.has(key))))
if (courseTrees.length > 0) {
const firstCourseKey = `course-${courseTrees[0].course_id}`
setExpandedCourses((previous) => (previous.size > 0 ? previous : new Set([firstCourseKey])))
}
}, [courseTrees])
useEffect(() => {
if (pendingRestoreScrollRef.current == null) return
if (courseHierarchyLoading) return
const targetY = pendingRestoreScrollRef.current
pendingRestoreScrollRef.current = null
window.setTimeout(() => {
window.scrollTo({ top: targetY, behavior: "smooth" })
}, 60)
}, [courseHierarchyLoading, courseTrees])
const handleActionClick = (label: string) => {
toast.info(`${label} UI is ready. Endpoint wiring can be added next.`)
}
const openCreateModuleModal = (courseId: number, level: LevelNode, levelKey: string) => {
setCreateModuleCourseId(courseId)
setCreateModuleLevelId(level.id)
setCreateModuleLevelKey(levelKey)
setCreateModuleUseDefaultNaming(false)
setCreateModuleDefaultTitle(getNextDefaultModuleName(level))
setCreateModuleTitle("")
setCreateModuleIconSource("url")
setCreateModuleIconUrl("")
setCreateModuleIconFile(null)
setCreateModuleDisplayOrder(level.modules.length)
setCreateModuleOpen(true)
}
const handleCreateModule = async () => {
if (createModuleLevelId == null || createModuleCourseId == null) return
const title = (createModuleUseDefaultNaming ? createModuleDefaultTitle : createModuleTitle).trim()
if (!title) {
toast.error("Module title is required")
return
}
setCreateModuleSaving(true)
try {
let uploadedIconUrl: string | undefined
if (createModuleIconSource === "url" && createModuleIconUrl.trim()) {
const uploadRes = await uploadImageFile(createModuleIconUrl.trim())
uploadedIconUrl = uploadRes.data?.data?.url?.trim() || undefined
if (!uploadedIconUrl) {
throw new Error("Icon upload from URL did not return a file URL")
}
} else if (createModuleIconSource === "file" && createModuleIconFile) {
const uploadRes = await uploadImageFile(createModuleIconFile)
uploadedIconUrl = uploadRes.data?.data?.url?.trim() || undefined
if (!uploadedIconUrl) {
throw new Error("Icon file upload did not return a file URL")
}
}
await createModule({
level_id: createModuleLevelId,
title,
icon_url: uploadedIconUrl,
display_order: createModuleDisplayOrder,
is_active: true,
})
toast.success("Module created")
setExpandedCourses((previous) => new Set(previous).add(`course-${createModuleCourseId}`))
setExpandedLevels((previous) => new Set(previous).add(createModuleLevelKey))
setCreateModuleOpen(false)
const refreshCourseIds =
selectedCourseId === "ALL" ? targetCourseIds : [createModuleCourseId]
await fetchHierarchiesForCourses(refreshCourseIds)
} catch (error) {
console.error(error)
toast.error("Failed to create module")
} finally {
setCreateModuleSaving(false)
}
}
const openDeleteModuleDialog = (courseId: number, levelTitle: string, module: ModuleNode, moduleKey: string) => {
if (deleteModuleSavingId != null) return
setDeleteModuleTarget({
courseId,
moduleId: module.id,
moduleTitle: module.title,
levelTitle,
moduleKey,
})
setDeleteModuleDialogOpen(true)
}
const openEditModuleDialog = (
courseId: number,
levelKey: string,
moduleKey: string,
module: ModuleNode,
moduleDisplayOrder: number,
) => {
if (editModuleSaving) return
const existingIconUrl = module.icon_url?.trim() ?? ""
setEditModuleTarget({
courseId,
moduleId: module.id,
moduleKey,
levelKey,
})
setEditModuleTitle(module.title)
setEditModuleDisplayOrder(moduleDisplayOrder)
setEditModuleIconSource("url")
setEditModuleIconUrl(existingIconUrl)
setEditModuleOriginalIconUrl(existingIconUrl)
setEditModuleIconFile(null)
setEditModuleDialogOpen(true)
}
const handleUpdateModule = async () => {
if (!editModuleTarget) return
const title = editModuleTitle.trim()
if (!title) {
toast.error("Module title is required")
return
}
setEditModuleSaving(true)
try {
const inputIconUrl = editModuleIconUrl.trim()
let uploadedIconUrl: string | undefined
if (editModuleIconSource === "file" && editModuleIconFile) {
const uploadRes = await uploadImageFile(editModuleIconFile)
uploadedIconUrl = uploadRes.data?.data?.url?.trim() || undefined
if (!uploadedIconUrl) {
throw new Error("Icon file upload did not return a file URL")
}
} else if (editModuleIconSource === "url") {
if (!inputIconUrl) uploadedIconUrl = undefined
else if (inputIconUrl === editModuleOriginalIconUrl) uploadedIconUrl = inputIconUrl
else {
const uploadRes = await uploadImageFile(inputIconUrl)
uploadedIconUrl = uploadRes.data?.data?.url?.trim() || undefined
if (!uploadedIconUrl) {
throw new Error("Icon upload from URL did not return a file URL")
}
}
}
await updateModule(editModuleTarget.moduleId, {
title,
icon_url: uploadedIconUrl,
display_order: editModuleDisplayOrder,
is_active: true,
})
toast.success("Module updated")
setExpandedCourses((previous) => new Set(previous).add(`course-${editModuleTarget.courseId}`))
setExpandedLevels((previous) => new Set(previous).add(editModuleTarget.levelKey))
setExpandedModules((previous) => new Set(previous).add(editModuleTarget.moduleKey))
setEditModuleDialogOpen(false)
setEditModuleTarget(null)
const refreshCourseIds = selectedCourseId === "ALL" ? targetCourseIds : [editModuleTarget.courseId]
await fetchHierarchiesForCourses(refreshCourseIds)
} catch (error) {
console.error(error)
toast.error("Failed to update module")
} finally {
setEditModuleSaving(false)
}
}
const handleDeleteModule = async () => {
if (deleteModuleSavingId != null) return
if (!deleteModuleTarget) return
setDeleteModuleSavingId(deleteModuleTarget.moduleId)
try {
await deleteModule(deleteModuleTarget.moduleId)
toast.success("Module deleted")
setExpandedModules((previous) => {
const next = new Set(previous)
next.delete(deleteModuleTarget.moduleKey)
return next
})
setDeleteModuleDialogOpen(false)
setDeleteModuleTarget(null)
const refreshCourseIds = selectedCourseId === "ALL" ? targetCourseIds : [deleteModuleTarget.courseId]
await fetchHierarchiesForCourses(refreshCourseIds)
} catch (error) {
console.error(error)
toast.error("Failed to delete module")
} finally {
setDeleteModuleSavingId(null)
}
}
const persistReturnState = () => {
const payload: HierarchyReturnState = {
selectedSubCategoryId,
selectedCourseId,
selectedLevelId,
expandedCourses: Array.from(expandedCourses),
expandedLevels: Array.from(expandedLevels),
expandedModules: Array.from(expandedModules),
scrollY: window.scrollY,
}
window.sessionStorage.setItem(HUMAN_LANGUAGE_RETURN_STATE_KEY, JSON.stringify(payload))
}
return (
<div className="mx-auto max-w-6xl space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand-50 text-brand-700 ring-1 ring-inset ring-brand-100">
<Languages className="h-5 w-5" aria-hidden />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900">Human Language</h1>
<p className="text-sm text-grayScale-500">Hierarchy management</p>
</div>
</div>
<Button type="button" variant="outline" asChild>
<Link to="/content">Back to Content Management</Link>
</Button>
</div>
<Card className="border border-grayScale-200 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-lg">Filters</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<label htmlFor="hl-subcategory-filter" className="text-xs font-semibold uppercase tracking-wide text-grayScale-500">
Subcategory
</label>
{hierarchyLoading ? (
<div className="flex items-center gap-2 py-2 text-sm text-grayScale-500">
<SpinnerIcon className="h-5 w-5 text-brand-500" alt="" />
Loading hierarchy...
</div>
) : hierarchyError ? (
<div className="flex flex-col gap-2">
<p className="text-sm text-red-600">{hierarchyError}</p>
<Button type="button" variant="outline" size="sm" onClick={() => void fetchHumanLanguageHierarchy()}>
Retry
</Button>
</div>
) : (
<Select
id="hl-subcategory-filter"
value={selectedSubCategoryId === "ALL" ? "ALL" : String(selectedSubCategoryId)}
onChange={(event) => {
const value = event.target.value
setSelectedSubCategoryId(value === "ALL" ? "ALL" : Number(value))
}}
>
<option value="ALL">All subcategories</option>
{subCategoryOptions.map((subCategory) => (
<option key={subCategory.id} value={String(subCategory.id)}>
{subCategory.name}
</option>
))}
</Select>
)}
</div>
<div className="space-y-2">
<label htmlFor="hl-course-filter" className="text-xs font-semibold uppercase tracking-wide text-grayScale-500">
Course
</label>
{selectedSubCategoryId === "ALL" ? (
<p className="rounded-lg border border-dashed border-grayScale-200 bg-grayScale-50/50 px-3 py-2 text-sm text-grayScale-500">
Select a sub-category first.
</p>
) : (
<Select
id="hl-course-filter"
value={selectedCourseId === "ALL" ? "ALL" : String(selectedCourseId)}
onChange={(event) => {
const value = event.target.value
setSelectedCourseId(value === "ALL" ? "ALL" : Number(value))
}}
>
<option value="ALL">All courses</option>
{courseOptions.map((course) => (
<option key={course.id} value={String(course.id)}>
{course.title}
</option>
))}
</Select>
)}
</div>
<div className="space-y-2">
<label htmlFor="hl-level-filter" className="text-xs font-semibold uppercase tracking-wide text-grayScale-500">
Fetch lessons by level
</label>
{selectedCourseId === "ALL" ? (
<Select id="hl-level-filter" value="ALL LEVELS" disabled>
<option value="ALL LEVELS">ALL LEVELS</option>
</Select>
) : (
<Select
id="hl-level-filter"
value={selectedLevelId === "ALL" ? "ALL" : String(selectedLevelId)}
onChange={(event) => {
const value = event.target.value
setSelectedLevelId(value === "ALL" ? "ALL" : Number(value))
}}
>
<option value="ALL">ALL LEVELS</option>
{courseTrees
.flatMap((course) => course.levels)
.map((level) => (
<option key={level.id} value={String(level.id)}>
{level.title}
</option>
))}
</Select>
)}
</div>
</div>
</CardContent>
</Card>
{selectedSubCategoryId !== "ALL" ? (
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" className="text-red-600" onClick={() => handleActionClick("Delete selected sub-category")}>
<Trash2 className="mr-2 h-4 w-4" />
Delete selected sub-category
</Button>
<Button type="button" variant="outline" className="text-red-600" onClick={() => handleActionClick("Delete selected course")}>
<Trash2 className="mr-2 h-4 w-4" />
Delete selected course
</Button>
</div>
) : null}
{selectedSubCategoryId === "ALL" ? (
<Card className="border border-dashed border-grayScale-200 bg-grayScale-50/40 shadow-sm">
<CardContent className="flex items-start gap-3 py-5">
<FolderTree className="mt-0.5 h-5 w-5 shrink-0 text-grayScale-400" aria-hidden />
<div>
<p className="text-sm font-medium text-grayScale-800">Select a sub-category to start managing hierarchy</p>
<p className="mt-1 text-sm text-grayScale-500">
Choose a sub-category from the list to view and manage its course structure.
</p>
</div>
</CardContent>
</Card>
) : courseHierarchyLoading ? (
<div className="flex items-center gap-2 py-4 text-sm text-grayScale-500">
<SpinnerIcon className="h-5 w-5 text-brand-500" alt="" />
Loading hierarchy tree...
</div>
) : courseHierarchyError ? (
<Card className="border border-red-200 bg-red-50/60">
<CardContent className="flex items-center justify-between gap-3 py-4">
<p className="text-sm text-red-700">{courseHierarchyError}</p>
<Button type="button" variant="outline" size="sm" onClick={() => void fetchHierarchiesForCourses(targetCourseIds)}>
Retry
</Button>
</CardContent>
</Card>
) : courseTrees.length === 0 ? (
<Card className="border border-dashed border-grayScale-200 bg-grayScale-50/40 shadow-sm">
<CardContent className="py-6 text-sm text-grayScale-500">No hierarchy records found for this selection.</CardContent>
</Card>
) : (
<div className="space-y-4">
{courseTrees.map((course) => {
const courseKey = `course-${course.course_id}`
const courseOpen = setHas(expandedCourses, courseKey)
return (
<Card key={course.course_id} className="border border-grayScale-200 shadow-sm">
<CardContent className="p-0">
<div className="flex items-center justify-between gap-3 border-b border-grayScale-200 px-4 py-3">
<button
type="button"
className="flex items-center gap-2 text-left"
onClick={() => toggleSetValue(setExpandedCourses, courseKey)}
>
{courseOpen ? <ChevronDown className="h-4 w-4 text-grayScale-500" /> : <ChevronRight className="h-4 w-4 text-grayScale-500" />}
<span className="font-semibold text-grayScale-900">{course.course_title}</span>
<Badge variant="secondary" className="text-xs">
{course.levels.length} levels
</Badge>
</button>
<Button type="button" className="bg-purple-600 hover:bg-purple-700" onClick={() => handleActionClick("Add next CEFR level")}>
Add next CEFR level
</Button>
</div>
{courseOpen ? (
<div className="space-y-3 p-4">
{course.levels.length === 0 ? (
<p className="text-sm text-grayScale-500">No levels for this course.</p>
) : (
course.levels.map((level) => {
const levelKey = `level-${course.course_id}-${level.id}`
const levelOpen = setHas(expandedLevels, levelKey)
const levelPractices = levelPracticesByLevelId[level.id] ?? []
return (
<div key={level.id} className="rounded-xl border border-grayScale-200">
<div className="flex items-center justify-between gap-3 border-b border-grayScale-200 px-3 py-2.5">
<button
type="button"
className="flex items-center gap-2 text-left"
onClick={() => toggleSetValue(setExpandedLevels, levelKey)}
>
{levelOpen ? (
<ChevronDown className="h-4 w-4 text-grayScale-500" />
) : (
<ChevronRight className="h-4 w-4 text-grayScale-500" />
)}
<span className="font-semibold text-grayScale-900">{level.title}</span>
<Badge variant="secondary" className="text-xs">
{level.modules.length} module(s)
</Badge>
</button>
<Button type="button" variant="outline" size="sm" className="text-red-600" onClick={() => handleActionClick("Remove level")}>
<Trash2 className="mr-1.5 h-4 w-4" />
Remove
</Button>
</div>
{levelOpen ? (
<div className="space-y-3 p-3">
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => openCreateModuleModal(course.course_id, level, levelKey)}
>
<Plus className="mr-1.5 h-4 w-4" />
Add Module
</Button>
</div>
{levelPractices.length > 0 ? (
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50/50 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-grayScale-500">Level practices</p>
<ul className="mt-2 space-y-1.5 text-sm text-grayScale-700">
{levelPractices.map((practice) => (
<li key={practice.id}>{practice.title}</li>
))}
</ul>
</div>
) : null}
{level.modules.length === 0 ? (
<p className="text-sm text-grayScale-500">No modules yet.</p>
) : (
<div className="space-y-2">
{level.modules.map((module) => {
const moduleKey = `module-${course.course_id}-${level.id}-${module.id}`
const moduleOpen = setHas(expandedModules, moduleKey)
return (
<div key={module.id} className="rounded-lg border border-grayScale-200">
<div className="flex items-center justify-between gap-3 px-3 py-2">
<button
type="button"
className="flex items-center gap-2 text-left"
onClick={() => toggleSetValue(setExpandedModules, moduleKey)}
>
{moduleOpen ? (
<ChevronDown className="h-4 w-4 text-grayScale-500" />
) : (
<ChevronRight className="h-4 w-4 text-grayScale-500" />
)}
{module.icon_url ? (
<img
src={module.icon_url}
alt=""
className="h-5 w-5 rounded object-cover ring-1 ring-grayScale-200"
/>
) : null}
<span className="font-medium text-grayScale-900">Module: {module.title}</span>
<Badge variant="secondary" className="text-xs">
{module.sub_modules.length} sub-module(s)
</Badge>
</button>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => handleActionClick("Add sub-module")}>
<Plus className="mr-1.5 h-4 w-4" />
Add Sub-module
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={editModuleSaving}
onClick={() =>
openEditModuleDialog(course.course_id, levelKey, moduleKey, module, level.modules.indexOf(module) + 1)
}
>
<Pencil className="mr-1.5 h-4 w-4" />
Edit
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="text-red-600"
disabled={deleteModuleSavingId === module.id}
onClick={() => openDeleteModuleDialog(course.course_id, level.title, module, moduleKey)}
>
<Trash2 className="mr-1.5 h-4 w-4" />
{deleteModuleSavingId === module.id ? "Removing..." : "Remove"}
</Button>
</div>
</div>
{moduleOpen ? (
<div className="space-y-2 border-t border-grayScale-200 p-3">
{module.sub_modules.length === 0 ? (
<p className="text-sm text-grayScale-500">No sub-modules yet.</p>
) : (
module.sub_modules.map((subModule) => (
<div
key={subModule.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-grayScale-200 px-3 py-2"
>
<div className="text-sm font-medium text-grayScale-900">
Sub-module: {subModule.title}
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" asChild>
<Link
to={`/content/human-language/${selectedSubCategory?.category_id ?? ""}/${course.course_id}/sub-module/${subModule.id}`}
onClick={persistReturnState}
>
Open
</Link>
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="text-red-600"
onClick={() => handleActionClick("Remove sub-module")}
>
<Trash2 className="mr-1.5 h-4 w-4" />
Remove
</Button>
</div>
</div>
))
)}
</div>
) : null}
</div>
)
})}
</div>
)}
</div>
) : null}
</div>
)
})
)}
</div>
) : null}
</CardContent>
</Card>
)
})}
</div>
)}
<Dialog open={createModuleOpen} onOpenChange={(open) => (!createModuleSaving ? setCreateModuleOpen(open) : null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Create module</DialogTitle>
<DialogDescription>
Add a module to this level.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Module naming</label>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant={!createModuleUseDefaultNaming ? "default" : "outline"}
className={!createModuleUseDefaultNaming ? "bg-brand-500 hover:bg-brand-600" : ""}
disabled={createModuleSaving}
onClick={() => setCreateModuleUseDefaultNaming(false)}
>
Custom title
</Button>
<Button
type="button"
variant={createModuleUseDefaultNaming ? "default" : "outline"}
className={createModuleUseDefaultNaming ? "bg-brand-500 hover:bg-brand-600" : ""}
disabled={createModuleSaving}
onClick={() => setCreateModuleUseDefaultNaming(true)}
>
Default naming
</Button>
</div>
{createModuleUseDefaultNaming ? (
<p className="mt-2 text-xs text-grayScale-500">Auto title: {createModuleDefaultTitle}</p>
) : null}
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Title</label>
<Input
value={createModuleUseDefaultNaming ? createModuleDefaultTitle : createModuleTitle}
onChange={(event) => setCreateModuleTitle(event.target.value)}
placeholder="e.g. Introduction"
disabled={createModuleSaving || createModuleUseDefaultNaming}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Icon URL (optional)</label>
<div className="mb-2 grid grid-cols-2 gap-2">
<Button
type="button"
variant={createModuleIconSource === "url" ? "default" : "outline"}
className={createModuleIconSource === "url" ? "bg-brand-500 hover:bg-brand-600" : ""}
disabled={createModuleSaving}
onClick={() => setCreateModuleIconSource("url")}
>
Public URL
</Button>
<Button
type="button"
variant={createModuleIconSource === "file" ? "default" : "outline"}
className={createModuleIconSource === "file" ? "bg-brand-500 hover:bg-brand-600" : ""}
disabled={createModuleSaving}
onClick={() => setCreateModuleIconSource("file")}
>
Upload from PC
</Button>
</div>
{createModuleIconSource === "url" ? (
<Input
value={createModuleIconUrl}
onChange={(event) => setCreateModuleIconUrl(event.target.value)}
placeholder="https://example.com/icon.png"
disabled={createModuleSaving}
/>
) : (
<Input
type="file"
accept="image/*"
disabled={createModuleSaving}
onChange={(event) => {
const file = event.target.files?.[0] ?? null
setCreateModuleIconFile(file)
}}
/>
)}
<p className="mt-1 text-xs text-grayScale-500">
Icon is uploaded through `/files/upload` and the returned MinIO URL is saved as `icon_url`.
</p>
</div>
</div>
<DialogFooter className="mt-2">
<Button
type="button"
variant="outline"
disabled={createModuleSaving}
onClick={() => setCreateModuleOpen(false)}
>
Cancel
</Button>
<Button
type="button"
className="bg-brand-500 hover:bg-brand-600"
disabled={createModuleSaving}
onClick={() => void handleCreateModule()}
>
{createModuleSaving ? "Creating..." : "Create module"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={editModuleDialogOpen}
onOpenChange={(open) => {
if (editModuleSaving) return
setEditModuleDialogOpen(open)
if (!open) setEditModuleTarget(null)
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Update module</DialogTitle>
<DialogDescription>
Update this module&apos;s name, order, and settings.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Title</label>
<Input
value={editModuleTitle}
onChange={(event) => setEditModuleTitle(event.target.value)}
placeholder="Updated title"
disabled={editModuleSaving}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Display order</label>
<Input
type="number"
min={0}
value={editModuleDisplayOrder}
onChange={(event) => setEditModuleDisplayOrder(Math.max(0, Number(event.target.value) || 0))}
disabled={editModuleSaving}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Icon URL (optional)</label>
<div className="mb-2 grid grid-cols-2 gap-2">
<Button
type="button"
variant={editModuleIconSource === "url" ? "default" : "outline"}
className={editModuleIconSource === "url" ? "bg-brand-500 hover:bg-brand-600" : ""}
disabled={editModuleSaving}
onClick={() => setEditModuleIconSource("url")}
>
Public URL
</Button>
<Button
type="button"
variant={editModuleIconSource === "file" ? "default" : "outline"}
className={editModuleIconSource === "file" ? "bg-brand-500 hover:bg-brand-600" : ""}
disabled={editModuleSaving}
onClick={() => setEditModuleIconSource("file")}
>
Upload from PC
</Button>
</div>
{editModuleIconSource === "url" ? (
<Input
value={editModuleIconUrl}
onChange={(event) => setEditModuleIconUrl(event.target.value)}
placeholder="https://example.com/icon.png"
disabled={editModuleSaving}
/>
) : (
<Input
type="file"
accept="image/*"
disabled={editModuleSaving}
onChange={(event) => {
const file = event.target.files?.[0] ?? null
setEditModuleIconFile(file)
}}
/>
)}
<p className="mt-1 text-xs text-grayScale-500">
If you provide a new icon, it is uploaded through `/files/upload`, then saved as `icon_url`.
</p>
</div>
</div>
<DialogFooter className="mt-2">
<Button
type="button"
variant="outline"
disabled={editModuleSaving}
onClick={() => {
setEditModuleDialogOpen(false)
setEditModuleTarget(null)
}}
>
Cancel
</Button>
<Button
type="button"
className="bg-brand-500 hover:bg-brand-600"
disabled={editModuleSaving || !editModuleTarget}
onClick={() => void handleUpdateModule()}
>
{editModuleSaving ? "Updating..." : "Update module"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={deleteModuleDialogOpen}
onOpenChange={(open) => {
if (deleteModuleSavingId != null) return
setDeleteModuleDialogOpen(open)
if (!open) setDeleteModuleTarget(null)
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete module?</DialogTitle>
<DialogDescription>
This will permanently remove{" "}
<span className="font-medium text-grayScale-700">{deleteModuleTarget?.moduleTitle ?? "this module"}</span> from{" "}
<span className="font-medium text-grayScale-700">{deleteModuleTarget?.levelTitle ?? "this level"}</span>.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={deleteModuleSavingId != null}
onClick={() => {
setDeleteModuleDialogOpen(false)
setDeleteModuleTarget(null)
}}
>
Cancel
</Button>
<Button
type="button"
className="bg-red-600 hover:bg-red-700"
disabled={deleteModuleSavingId != null || !deleteModuleTarget}
onClick={() => void handleDeleteModule()}
>
{deleteModuleSavingId != null ? "Deleting..." : "Delete module"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}