Restructure Human Language hierarchy: path parent, levels nested.

Render each course path (e.g. English - Speaking) as the top card with CEFR levels, modules, and sub-modules nested inside; use per-path collapse keys for level rows.

Made-with: Cursor
This commit is contained in:
Yared Yemane 2026-04-07 08:05:15 -07:00
parent c664c3ad67
commit 7f59ce4b2f

View File

@ -18,7 +18,7 @@ export function HumanLanguagePage() {
const [selectedSubCategoryId, setSelectedSubCategoryId] = useState<number | "ALL">("ALL") const [selectedSubCategoryId, setSelectedSubCategoryId] = useState<number | "ALL">("ALL")
const [selectedCourseId, setSelectedCourseId] = useState<number | "ALL">("ALL") const [selectedCourseId, setSelectedCourseId] = useState<number | "ALL">("ALL")
const [selectedLevel, setSelectedLevel] = useState<CefrLevel | "ALL">("ALL") const [selectedLevel, setSelectedLevel] = useState<CefrLevel | "ALL">("ALL")
const [collapsedLevels, setCollapsedLevels] = useState<CefrLevel[]>([]) const [collapsedLevels, setCollapsedLevels] = useState<string[]>([])
const [creatingKey, setCreatingKey] = useState<string | null>(null) const [creatingKey, setCreatingKey] = useState<string | null>(null)
const [quickSubCategoryName, setQuickSubCategoryName] = useState("") const [quickSubCategoryName, setQuickSubCategoryName] = useState("")
const [quickCourseName, setQuickCourseName] = useState("") const [quickCourseName, setQuickCourseName] = useState("")
@ -111,8 +111,8 @@ export function HumanLanguagePage() {
} }
}, [selectedLevel, visibleCefrLevels]) }, [selectedLevel, visibleCefrLevels])
const toggleLevel = (level: CefrLevel) => { const toggleLevel = (levelKey: string) => {
setCollapsedLevels((prev) => (prev.includes(level) ? prev.filter((l) => l !== level) : [...prev, level])) setCollapsedLevels((prev) => (prev.includes(levelKey) ? prev.filter((l) => l !== levelKey) : [...prev, levelKey]))
} }
const parseModuleNumber = (title: string): number | null => { const parseModuleNumber = (title: string): number | null => {
@ -429,78 +429,67 @@ export function HumanLanguagePage() {
) : null} ) : null}
{availableCourses.length > 0 {availableCourses.length > 0
? visibleCefrLevels ? selectedCourses.map((course: HumanLanguageCourseTree) => {
.filter((l) => selectedLevel === "ALL" || l === selectedLevel) const courseLevels = CEFR_LEVELS.filter((level) => {
.map((level) => { if (level === "A1") return true
const modulesByCourse = selectedCourses.map((course: HumanLanguageCourseTree) => { const node = course.levels.find((item) => item.level.toUpperCase() === level)
const levelNode = course.levels.find((item) => item.level.toUpperCase() === level) return (node?.modules?.length ?? 0) > 0
return { }).filter((level) => selectedLevel === "ALL" || selectedLevel === level)
course,
modules: levelNode?.modules ?? [],
}
})
const levelRemoveIds =
resolvedCourseId == null
? []
: (() => {
const courseEntry = modulesByCourse.find((entry) => entry.course.course_id === resolvedCourseId)
return (courseEntry?.modules ?? []).flatMap((m) => m.sub_modules.map((s) => s.id))
})()
const canRemoveLevel = resolvedCourseId != null && levelRemoveIds.length > 0
return ( return (
<Card key={level} className="overflow-hidden border-grayScale-200/80 shadow-sm"> <Card key={course.course_id} className="overflow-hidden border-grayScale-200/80 shadow-sm">
<div className="flex items-center justify-between border-b border-grayScale-100 bg-white px-4 py-3">
<p className="text-base font-semibold text-brand-700">{course.course_name}</p>
</div>
<CardContent className="space-y-3 p-4">
{courseLevels.length === 0 ? (
<p className="text-sm text-grayScale-500">No levels match the current level filter.</p>
) : (
courseLevels.map((level) => {
const levelNode = course.levels.find((item) => item.level.toUpperCase() === level)
const modules = levelNode?.modules ?? []
const levelKey = `${course.course_id}-${level}`
const levelRemoveIds = modules.flatMap((m) => m.sub_modules.map((s) => s.id))
const canRemoveLevel = levelRemoveIds.length > 0
return (
<div key={levelKey} className="overflow-hidden rounded-lg border border-grayScale-200/90">
<div className="flex w-full flex-wrap items-center justify-between gap-2 border-b border-grayScale-100 bg-grayScale-50/60 px-4 py-3"> <div className="flex w-full flex-wrap items-center justify-between gap-2 border-b border-grayScale-100 bg-grayScale-50/60 px-4 py-3">
<button <button
type="button" type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left" className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => toggleLevel(level)} onClick={() => toggleLevel(levelKey)}
> >
{collapsedLevels.includes(level) ? <ChevronRight className="h-4 w-4 shrink-0" /> : <ChevronDown className="h-4 w-4 shrink-0" />} {collapsedLevels.includes(levelKey) ? <ChevronRight className="h-4 w-4 shrink-0" /> : <ChevronDown className="h-4 w-4 shrink-0" />}
<span className="text-sm font-semibold text-grayScale-900">{level}</span> <span className="text-sm font-semibold text-grayScale-900">{level}</span>
<span className="rounded-md bg-brand-100 px-2 py-0.5 text-xs font-medium text-brand-700"> <span className="rounded-md bg-brand-100 px-2 py-0.5 text-xs font-medium text-brand-700">
{modulesByCourse.reduce((sum, entry) => sum + entry.modules.length, 0)} module(s) {modules.length} module(s)
</span> </span>
</button> </button>
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant="outline" variant="outline"
title={ title={!canRemoveLevel ? "Nothing to remove at this level" : `Remove all content at ${level} for ${course.course_name}`}
resolvedCourseId == null
? "Select a course (or narrow to one course) to remove this level"
: !canRemoveLevel
? "Nothing to remove at this level"
: `Remove all content at ${level} for the selected course`
}
className="h-8 shrink-0 gap-1 border-red-200/90 px-2.5 text-xs font-medium text-red-600 hover:bg-red-50" className="h-8 shrink-0 gap-1 border-red-200/90 px-2.5 text-xs font-medium text-red-600 hover:bg-red-50"
disabled={!canRemoveLevel || (resolvedCourseId != null && deletingKey === `level-${resolvedCourseId}-${level}`)} disabled={!canRemoveLevel || deletingKey === `level-${course.course_id}-${level}`}
onClick={() => { onClick={() =>
if (!canRemoveLevel || resolvedCourseId == null) return handleDeleteSubModules(levelRemoveIds, `level-${course.course_id}-${level}`, `Level ${level} removed`)
const courseEntry = modulesByCourse.find((entry) => entry.course.course_id === resolvedCourseId) }
const ids = (courseEntry?.modules ?? []).flatMap((m) => m.sub_modules.map((s) => s.id))
handleDeleteSubModules(ids, `level-${resolvedCourseId}-${level}`, `Level ${level} removed`)
}}
> >
<Trash2 className="h-3 w-3.5" aria-hidden /> <Trash2 className="h-3 w-3.5" aria-hidden />
Remove Remove
</Button> </Button>
</div> </div>
{!collapsedLevels.includes(level) ? ( {!collapsedLevels.includes(levelKey) ? (
<CardContent className="space-y-3 p-4"> <div className="space-y-2 p-3">
{modulesByCourse.length === 0 ? (
<p className="text-sm text-grayScale-500">No lessons found for this level.</p>
) : (
modulesByCourse.map((entry) => (
<div key={entry.course.course_id} className="space-y-2 rounded-xl border border-grayScale-200 bg-white p-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-brand-700">{entry.course.course_name}</p>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => handleCreateModule(entry.course.course_id, level, entry.modules)} onClick={() => handleCreateModule(course.course_id, level, modules)}
disabled={creatingKey === `module-${entry.course.course_id}-${level}`} disabled={creatingKey === `module-${course.course_id}-${level}`}
> >
{creatingKey === `module-${entry.course.course_id}-${level}` ? ( {creatingKey === `module-${course.course_id}-${level}` ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
) : ( ) : (
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
@ -508,11 +497,10 @@ export function HumanLanguagePage() {
Add Module Add Module
</Button> </Button>
</div> </div>
<div className="space-y-2"> {modules.length === 0 ? (
{entry.modules.length === 0 ? (
<p className="text-xs text-grayScale-500">No modules yet. Use Add Module to start.</p> <p className="text-xs text-grayScale-500">No modules yet. Use Add Module to start.</p>
) : ( ) : (
entry.modules.map((module) => ( modules.map((module) => (
<div key={module.id} className="rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-3"> <div key={module.id} className="rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-grayScale-900">Module: {module.title}</p> <p className="text-sm font-semibold text-grayScale-900">Module: {module.title}</p>
@ -521,11 +509,11 @@ export function HumanLanguagePage() {
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => onClick={() =>
handleCreateSubModule(entry.course.course_id, level, module.title, module.sub_modules) handleCreateSubModule(course.course_id, level, module.title, module.sub_modules)
} }
disabled={creatingKey === `submodule-${entry.course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}`} disabled={creatingKey === `submodule-${course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}`}
> >
{creatingKey === `submodule-${entry.course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}` ? ( {creatingKey === `submodule-${course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}` ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
) : ( ) : (
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
@ -557,10 +545,10 @@ export function HumanLanguagePage() {
<p className="text-xs font-semibold text-grayScale-700">Sub-module: {subModule.title}</p> <p className="text-xs font-semibold text-grayScale-700">Sub-module: {subModule.title}</p>
{categoryId ? ( {categoryId ? (
<div className="flex gap-2"> <div className="flex gap-2">
<Link to={`/content/category/${categoryId}/courses/${entry.course.course_id}/sub-courses/${subModule.id}`}> <Link to={`/content/category/${categoryId}/courses/${course.course_id}/sub-courses/${subModule.id}`}>
<Button size="sm" variant="outline">Manage lesson videos/audio</Button> <Button size="sm" variant="outline">Manage lesson videos/audio</Button>
</Link> </Link>
<Link to={`/content/category/${categoryId}/courses/${entry.course.course_id}/sub-courses/${subModule.id}/add-practice?source=human-language`}> <Link to={`/content/category/${categoryId}/courses/${course.course_id}/sub-courses/${subModule.id}/add-practice?source=human-language`}>
<Button size="sm">Add practice/audio questions</Button> <Button size="sm">Add practice/audio questions</Button>
</Link> </Link>
<Button <Button
@ -602,11 +590,12 @@ export function HumanLanguagePage() {
)) ))
)} )}
</div> </div>
) : null}
</div> </div>
)) )
})
)} )}
</CardContent> </CardContent>
) : null}
</Card> </Card>
) )
}) })