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:
parent
c664c3ad67
commit
7f59ce4b2f
|
|
@ -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,184 +429,173 @@ 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 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 items-center justify-between border-b border-grayScale-100 bg-white px-4 py-3">
|
||||||
<button
|
<p className="text-base font-semibold text-brand-700">{course.course_name}</p>
|
||||||
type="button"
|
|
||||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
|
||||||
onClick={() => toggleLevel(level)}
|
|
||||||
>
|
|
||||||
{collapsedLevels.includes(level) ? <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="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)
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
title={
|
|
||||||
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"
|
|
||||||
disabled={!canRemoveLevel || (resolvedCourseId != null && deletingKey === `level-${resolvedCourseId}-${level}`)}
|
|
||||||
onClick={() => {
|
|
||||||
if (!canRemoveLevel || resolvedCourseId == null) return
|
|
||||||
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 />
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{!collapsedLevels.includes(level) ? (
|
<CardContent className="space-y-3 p-4">
|
||||||
<CardContent className="space-y-3 p-4">
|
{courseLevels.length === 0 ? (
|
||||||
{modulesByCourse.length === 0 ? (
|
<p className="text-sm text-grayScale-500">No levels match the current level filter.</p>
|
||||||
<p className="text-sm text-grayScale-500">No lessons found for this level.</p>
|
) : (
|
||||||
) : (
|
courseLevels.map((level) => {
|
||||||
modulesByCourse.map((entry) => (
|
const levelNode = course.levels.find((item) => item.level.toUpperCase() === level)
|
||||||
<div key={entry.course.course_id} className="space-y-2 rounded-xl border border-grayScale-200 bg-white p-3">
|
const modules = levelNode?.modules ?? []
|
||||||
<div className="flex items-center justify-between gap-2">
|
const levelKey = `${course.course_id}-${level}`
|
||||||
<p className="text-sm font-semibold text-brand-700">{entry.course.course_name}</p>
|
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">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||||
|
onClick={() => toggleLevel(levelKey)}
|
||||||
|
>
|
||||||
|
{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="rounded-md bg-brand-100 px-2 py-0.5 text-xs font-medium text-brand-700">
|
||||||
|
{modules.length} module(s)
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => handleCreateModule(entry.course.course_id, level, entry.modules)}
|
title={!canRemoveLevel ? "Nothing to remove at this level" : `Remove all content at ${level} for ${course.course_name}`}
|
||||||
disabled={creatingKey === `module-${entry.course.course_id}-${level}`}
|
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 || deletingKey === `level-${course.course_id}-${level}`}
|
||||||
|
onClick={() =>
|
||||||
|
handleDeleteSubModules(levelRemoveIds, `level-${course.course_id}-${level}`, `Level ${level} removed`)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{creatingKey === `module-${entry.course.course_id}-${level}` ? (
|
<Trash2 className="h-3 w-3.5" aria-hidden />
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
Remove
|
||||||
) : (
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
Add Module
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
{!collapsedLevels.includes(levelKey) ? (
|
||||||
{entry.modules.length === 0 ? (
|
<div className="space-y-2 p-3">
|
||||||
<p className="text-xs text-grayScale-500">No modules yet. Use “Add Module” to start.</p>
|
<div className="flex items-center justify-between gap-2">
|
||||||
) : (
|
<Button
|
||||||
entry.modules.map((module) => (
|
size="sm"
|
||||||
<div key={module.id} className="rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-3">
|
variant="outline"
|
||||||
<div className="flex items-center justify-between gap-2">
|
onClick={() => handleCreateModule(course.course_id, level, modules)}
|
||||||
<p className="text-sm font-semibold text-grayScale-900">Module: {module.title}</p>
|
disabled={creatingKey === `module-${course.course_id}-${level}`}
|
||||||
<div className="flex gap-2">
|
>
|
||||||
<Button
|
{creatingKey === `module-${course.course_id}-${level}` ? (
|
||||||
size="sm"
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
variant="outline"
|
) : (
|
||||||
onClick={() =>
|
<Plus className="h-3.5 w-3.5" />
|
||||||
handleCreateSubModule(entry.course.course_id, level, module.title, module.sub_modules)
|
)}
|
||||||
}
|
Add Module
|
||||||
disabled={creatingKey === `submodule-${entry.course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}`}
|
</Button>
|
||||||
>
|
</div>
|
||||||
{creatingKey === `submodule-${entry.course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}` ? (
|
{modules.length === 0 ? (
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<p className="text-xs text-grayScale-500">No modules yet. Use “Add Module” to start.</p>
|
||||||
) : (
|
) : (
|
||||||
<Plus className="h-3.5 w-3.5" />
|
modules.map((module) => (
|
||||||
)}
|
<div key={module.id} className="rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-3">
|
||||||
Add Sub-module
|
<div className="flex items-center justify-between gap-2">
|
||||||
</Button>
|
<p className="text-sm font-semibold text-grayScale-900">Module: {module.title}</p>
|
||||||
<Button
|
<div className="flex gap-2">
|
||||||
type="button"
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 gap-1 border-red-200/90 px-2.5 text-xs font-medium text-red-600 hover:bg-red-50"
|
onClick={() =>
|
||||||
disabled={deletingKey === `module-${module.id}`}
|
handleCreateSubModule(course.course_id, level, module.title, module.sub_modules)
|
||||||
onClick={() =>
|
}
|
||||||
handleDeleteSubModules(
|
disabled={creatingKey === `submodule-${course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}`}
|
||||||
module.sub_modules.map((s) => s.id),
|
>
|
||||||
`module-${module.id}`,
|
{creatingKey === `submodule-${course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}` ? (
|
||||||
`Module ${module.title} removed`,
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
)
|
) : (
|
||||||
}
|
<Plus className="h-3.5 w-3.5" />
|
||||||
>
|
)}
|
||||||
<Trash2 className="h-3 w-3.5" aria-hidden />
|
Add Sub-module
|
||||||
Remove
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 gap-1 border-red-200/90 px-2.5 text-xs font-medium text-red-600 hover:bg-red-50"
|
||||||
|
disabled={deletingKey === `module-${module.id}`}
|
||||||
|
onClick={() =>
|
||||||
|
handleDeleteSubModules(
|
||||||
|
module.sub_modules.map((s) => s.id),
|
||||||
|
`module-${module.id}`,
|
||||||
|
`Module ${module.title} removed`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3.5" aria-hidden />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{module.sub_modules.map((subModule) => (
|
||||||
|
<div key={subModule.id} className="mt-2 rounded-md border border-grayScale-100 bg-white p-2">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="text-xs font-semibold text-grayScale-700">Sub-module: {subModule.title}</p>
|
||||||
|
{categoryId ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link to={`/content/category/${categoryId}/courses/${course.course_id}/sub-courses/${subModule.id}`}>
|
||||||
|
<Button size="sm" variant="outline">Manage lesson videos/audio</Button>
|
||||||
|
</Link>
|
||||||
|
<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>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 gap-1 border-red-200/90 px-2.5 text-xs font-medium text-red-600 hover:bg-red-50"
|
||||||
|
disabled={deletingKey === `submodule-${subModule.id}`}
|
||||||
|
onClick={() =>
|
||||||
|
handleDeleteSubModules(
|
||||||
|
[subModule.id],
|
||||||
|
`submodule-${subModule.id}`,
|
||||||
|
`Sub-module ${subModule.title} removed`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3.5" aria-hidden />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{subModule.videos.map((video) => (
|
||||||
|
<div key={video.id} className="inline-flex items-center gap-2 rounded-md bg-grayScale-50 px-2 py-1 text-xs text-grayScale-700">
|
||||||
|
<BookOpen className="h-3.5 w-3.5" />
|
||||||
|
{video.title}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{subModule.practices.map((practice) => (
|
||||||
|
<div key={practice.id} className="rounded-md bg-brand-50 px-2 py-1 text-xs text-brand-700">
|
||||||
|
Practice: {practice.title} ({practice.question_count} audio question(s))
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{module.sub_modules.map((subModule) => (
|
))
|
||||||
<div key={subModule.id} className="mt-2 rounded-md border border-grayScale-100 bg-white p-2">
|
)}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
</div>
|
||||||
<p className="text-xs font-semibold text-grayScale-700">Sub-module: {subModule.title}</p>
|
) : null}
|
||||||
{categoryId ? (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Link to={`/content/category/${categoryId}/courses/${entry.course.course_id}/sub-courses/${subModule.id}`}>
|
|
||||||
<Button size="sm" variant="outline">Manage lesson videos/audio</Button>
|
|
||||||
</Link>
|
|
||||||
<Link to={`/content/category/${categoryId}/courses/${entry.course.course_id}/sub-courses/${subModule.id}/add-practice?source=human-language`}>
|
|
||||||
<Button size="sm">Add practice/audio questions</Button>
|
|
||||||
</Link>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="h-8 gap-1 border-red-200/90 px-2.5 text-xs font-medium text-red-600 hover:bg-red-50"
|
|
||||||
disabled={deletingKey === `submodule-${subModule.id}`}
|
|
||||||
onClick={() =>
|
|
||||||
handleDeleteSubModules(
|
|
||||||
[subModule.id],
|
|
||||||
`submodule-${subModule.id}`,
|
|
||||||
`Sub-module ${subModule.title} removed`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3.5" aria-hidden />
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
|
||||||
{subModule.videos.map((video) => (
|
|
||||||
<div key={video.id} className="inline-flex items-center gap-2 rounded-md bg-grayScale-50 px-2 py-1 text-xs text-grayScale-700">
|
|
||||||
<BookOpen className="h-3.5 w-3.5" />
|
|
||||||
{video.title}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{subModule.practices.map((practice) => (
|
|
||||||
<div key={practice.id} className="rounded-md bg-brand-50 px-2 py-1 text-xs text-brand-700">
|
|
||||||
Practice: {practice.title} ({practice.question_count} audio question(s))
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
)
|
||||||
)}
|
})
|
||||||
</CardContent>
|
)}
|
||||||
) : null}
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user