render human language hierarchy in admin page
Integrate the admin Human Language page with the new hierarchy API and present subcategory/course/level/module/sub-module tree including videos and practices. Made-with: Cursor
This commit is contained in:
parent
882db5444d
commit
0763b77d66
|
|
@ -48,6 +48,7 @@ import type {
|
|||
AddSubCoursePrerequisiteRequest,
|
||||
GetLearningPathResponse,
|
||||
GetHumanLanguageLessonsResponse,
|
||||
GetHumanLanguageHierarchyResponse,
|
||||
GetSubCourseEntryAssessmentResponse,
|
||||
ReorderItem,
|
||||
GetRatingsResponse,
|
||||
|
|
@ -292,6 +293,9 @@ export const getHumanLanguageLessonsByCourse = (courseId: number, cefr_level: st
|
|||
params: { cefr_level },
|
||||
})
|
||||
|
||||
export const getHumanLanguageHierarchy = () =>
|
||||
http.get<GetHumanLanguageHierarchyResponse>("/course-management/human-language/hierarchy")
|
||||
|
||||
export const getSubCourseEntryAssessment = (subCourseId: number) =>
|
||||
http.get<GetSubCourseEntryAssessmentResponse>(
|
||||
`/question-sets/sub-courses/${subCourseId}/entry-assessment`,
|
||||
|
|
|
|||
|
|
@ -4,99 +4,55 @@ import { BookOpen, ChevronDown, ChevronRight, Languages } from "lucide-react"
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||
import { getCourseCategories, getCoursesByCategory, getHumanLanguageLessonsByCourse } from "../../api/courses.api"
|
||||
import type { Course, HumanLanguageLesson } from "../../types/course.types"
|
||||
import { getHumanLanguageHierarchy } from "../../api/courses.api"
|
||||
import type { HumanLanguageCourseTree, HumanLanguageSubCategoryTree } from "../../types/course.types"
|
||||
|
||||
const CEFR_LEVELS = ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3"] as const
|
||||
type CefrLevel = (typeof CEFR_LEVELS)[number]
|
||||
|
||||
export function HumanLanguagePage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [courses, setCourses] = useState<Course[]>([])
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null)
|
||||
const [selectedCourseId, setSelectedCourseId] = useState<number | null>(null)
|
||||
const [selectedLessonId, setSelectedLessonId] = useState<number | "ALL">("ALL")
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||
const [subCategories, setSubCategories] = useState<HumanLanguageSubCategoryTree[]>([])
|
||||
const [selectedSubCategoryId, setSelectedSubCategoryId] = useState<number | "ALL">("ALL")
|
||||
const [selectedCourseId, setSelectedCourseId] = useState<number | "ALL">("ALL")
|
||||
const [selectedLevel, setSelectedLevel] = useState<CefrLevel | "ALL">("ALL")
|
||||
const [collapsedLevels, setCollapsedLevels] = useState<CefrLevel[]>([])
|
||||
const [lessonsByLevel, setLessonsByLevel] = useState<Record<CefrLevel, HumanLanguageLesson[]>>(
|
||||
Object.fromEntries(CEFR_LEVELS.map((level) => [level, []])) as Record<CefrLevel, HumanLanguageLesson[]>,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
const loadHierarchy = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getCourseCategories()
|
||||
const items = res.data?.data?.categories ?? []
|
||||
const humanLanguageCategory =
|
||||
items.find((c) => c.name.toLowerCase().includes("human language")) ??
|
||||
items.find((c) => c.name.toLowerCase().includes("language")) ??
|
||||
null
|
||||
setSelectedCategoryId(humanLanguageCategory?.id ?? (items[0]?.id ?? null))
|
||||
const res = await getHumanLanguageHierarchy()
|
||||
const data = res.data?.data
|
||||
setCategoryId(data?.category_id ?? null)
|
||||
setSubCategories(data?.sub_categories ?? [])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
loadCategories().catch(() => undefined)
|
||||
loadHierarchy().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCategoryId) return
|
||||
const loadCourses = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getCoursesByCategory(selectedCategoryId)
|
||||
const items = res.data?.data?.courses ?? []
|
||||
setCourses(items)
|
||||
setSelectedCourseId(items[0]?.id ?? null)
|
||||
setSelectedLessonId("ALL")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
loadCourses().catch(() => undefined)
|
||||
}, [selectedCategoryId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCourseId) return
|
||||
const loadByLevels = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
if (selectedLevel === "ALL") {
|
||||
const entries = await Promise.all(
|
||||
CEFR_LEVELS.map(async (level) => {
|
||||
const res = await getHumanLanguageLessonsByCourse(selectedCourseId, level)
|
||||
return [level, res.data?.data?.lessons ?? []] as const
|
||||
}),
|
||||
const filteredSubCategories = useMemo(
|
||||
() =>
|
||||
selectedSubCategoryId === "ALL"
|
||||
? subCategories
|
||||
: subCategories.filter((s) => s.sub_category_id === selectedSubCategoryId),
|
||||
[subCategories, selectedSubCategoryId],
|
||||
)
|
||||
setLessonsByLevel(Object.fromEntries(entries) as Record<CefrLevel, HumanLanguageLesson[]>)
|
||||
} else {
|
||||
const res = await getHumanLanguageLessonsByCourse(selectedCourseId, selectedLevel)
|
||||
setLessonsByLevel((prev) => ({ ...prev, [selectedLevel]: res.data?.data?.lessons ?? [] }))
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
loadByLevels().catch(() => undefined)
|
||||
}, [selectedCourseId, selectedLevel])
|
||||
|
||||
const levelRows = useMemo(
|
||||
() => (selectedLevel === "ALL" ? CEFR_LEVELS : [selectedLevel]).map((level) => ({ level, rows: lessonsByLevel[level] })),
|
||||
[lessonsByLevel, selectedLevel],
|
||||
const availableCourses = useMemo(() => {
|
||||
return filteredSubCategories.flatMap((s) => s.courses)
|
||||
}, [filteredSubCategories])
|
||||
|
||||
const selectedCourses = useMemo(
|
||||
() =>
|
||||
selectedCourseId === "ALL"
|
||||
? availableCourses
|
||||
: availableCourses.filter((c) => c.course_id === selectedCourseId),
|
||||
[availableCourses, selectedCourseId],
|
||||
)
|
||||
const lessonOptions = useMemo(() => {
|
||||
const seen = new Set<number>()
|
||||
const options: { id: number; title: string }[] = []
|
||||
for (const rows of Object.values(lessonsByLevel)) {
|
||||
for (const lesson of rows) {
|
||||
if (seen.has(lesson.id)) continue
|
||||
seen.add(lesson.id)
|
||||
options.push({ id: lesson.id, title: lesson.title })
|
||||
}
|
||||
}
|
||||
return options.sort((a, b) => a.title.localeCompare(b.title))
|
||||
}, [lessonsByLevel])
|
||||
|
||||
const toggleLevel = (level: CefrLevel) => {
|
||||
setCollapsedLevels((prev) => (prev.includes(level) ? prev.filter((l) => l !== level) : [...prev, level]))
|
||||
|
|
@ -127,12 +83,15 @@ export function HumanLanguagePage() {
|
|||
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">Subcategory</label>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-grayScale-200 bg-white px-3 text-sm"
|
||||
value={selectedCourseId ?? ""}
|
||||
onChange={(e) => setSelectedCourseId(Number(e.target.value))}
|
||||
value={selectedSubCategoryId}
|
||||
onChange={(e) =>
|
||||
setSelectedSubCategoryId(e.target.value === "ALL" ? "ALL" : Number(e.target.value))
|
||||
}
|
||||
>
|
||||
{courses.map((course) => (
|
||||
<option key={course.id} value={course.id}>
|
||||
{course.title}
|
||||
<option value="ALL">All subcategories</option>
|
||||
{subCategories.map((subCategory) => (
|
||||
<option key={subCategory.sub_category_id} value={subCategory.sub_category_id}>
|
||||
{subCategory.sub_category_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -141,15 +100,15 @@ export function HumanLanguagePage() {
|
|||
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">Course</label>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-grayScale-200 bg-white px-3 text-sm"
|
||||
value={selectedLessonId}
|
||||
value={selectedCourseId}
|
||||
onChange={(e) =>
|
||||
setSelectedLessonId(e.target.value === "ALL" ? "ALL" : Number(e.target.value))
|
||||
setSelectedCourseId(e.target.value === "ALL" ? "ALL" : Number(e.target.value))
|
||||
}
|
||||
>
|
||||
<option value="ALL">All courses</option>
|
||||
{lessonOptions.map((lesson) => (
|
||||
<option key={lesson.id} value={lesson.id}>
|
||||
{lesson.title}
|
||||
{availableCourses.map((course) => (
|
||||
<option key={course.course_id} value={course.course_id}>
|
||||
{course.course_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -172,9 +131,9 @@ export function HumanLanguagePage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedCategoryId && selectedCourseId ? (
|
||||
{categoryId && selectedCourseId !== "ALL" ? (
|
||||
<div className="flex justify-end">
|
||||
<Link to={`/content/category/${selectedCategoryId}/courses/${selectedCourseId}/sub-courses`}>
|
||||
<Link to={`/content/category/${categoryId}/courses/${selectedCourseId}/sub-courses`}>
|
||||
<Button variant="outline">Open detailed management</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -187,9 +146,16 @@ export function HumanLanguagePage() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{levelRows.map(({ level, rows }) => {
|
||||
const filteredRows =
|
||||
selectedLessonId === "ALL" ? rows : rows.filter((lesson) => lesson.id === selectedLessonId)
|
||||
{CEFR_LEVELS.filter((l) => selectedLevel === "ALL" || l === selectedLevel).map((level) => {
|
||||
const modulesByCourse = selectedCourses
|
||||
.map((course: HumanLanguageCourseTree) => {
|
||||
const levelNode = course.levels.find((item) => item.level.toUpperCase() === level)
|
||||
return {
|
||||
course,
|
||||
modules: levelNode?.modules ?? [],
|
||||
}
|
||||
})
|
||||
.filter((entry) => entry.modules.length > 0)
|
||||
return (
|
||||
<Card key={level} className="overflow-hidden border-grayScale-200/80 shadow-sm">
|
||||
<button
|
||||
|
|
@ -201,28 +167,42 @@ export function HumanLanguagePage() {
|
|||
{collapsedLevels.includes(level) ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
<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">
|
||||
{filteredRows.length} unit(s)
|
||||
{modulesByCourse.reduce((sum, entry) => sum + entry.modules.length, 0)} module(s)
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{!collapsedLevels.includes(level) ? (
|
||||
<CardContent className="space-y-3 p-4">
|
||||
{filteredRows.length === 0 ? (
|
||||
{modulesByCourse.length === 0 ? (
|
||||
<p className="text-sm text-grayScale-500">No lessons found for this level.</p>
|
||||
) : (
|
||||
filteredRows.map((lesson) => (
|
||||
<div key={lesson.id} className="rounded-xl border border-grayScale-200 bg-white p-3">
|
||||
<p className="text-sm font-semibold text-grayScale-900">{lesson.title}</p>
|
||||
<p className="mt-1 text-xs text-grayScale-500">
|
||||
{lesson.video_count} lesson video(s) • {lesson.practice_count} practice(s)
|
||||
</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
{lesson.videos.map((video) => (
|
||||
modulesByCourse.map((entry) => (
|
||||
<div key={entry.course.course_id} className="space-y-2 rounded-xl border border-grayScale-200 bg-white p-3">
|
||||
<p className="text-sm font-semibold text-brand-700">{entry.course.course_name}</p>
|
||||
<div className="space-y-2">
|
||||
{entry.modules.map((module) => (
|
||||
<div key={module.id} className="rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-3">
|
||||
<p className="text-sm font-semibold text-grayScale-900">Module: {module.title}</p>
|
||||
{module.sub_modules.map((subModule) => (
|
||||
<div key={subModule.id} className="mt-2 rounded-md border border-grayScale-100 bg-white p-2">
|
||||
<p className="text-xs font-semibold text-grayScale-700">Sub-module: {subModule.title}</p>
|
||||
<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>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -700,6 +700,48 @@ export interface GetHumanLanguageLessonsResponse {
|
|||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface HumanLanguageSubModule {
|
||||
id: number
|
||||
title: string
|
||||
videos: LearningPathVideo[]
|
||||
practices: LearningPathPractice[]
|
||||
}
|
||||
|
||||
export interface HumanLanguageModule {
|
||||
id: number
|
||||
title: string
|
||||
sub_modules: HumanLanguageSubModule[]
|
||||
}
|
||||
|
||||
export interface HumanLanguageLevelTree {
|
||||
level: string
|
||||
modules: HumanLanguageModule[]
|
||||
}
|
||||
|
||||
export interface HumanLanguageCourseTree {
|
||||
course_id: number
|
||||
course_name: string
|
||||
levels: HumanLanguageLevelTree[]
|
||||
}
|
||||
|
||||
export interface HumanLanguageSubCategoryTree {
|
||||
sub_category_id: number
|
||||
sub_category_name: string
|
||||
courses: HumanLanguageCourseTree[]
|
||||
}
|
||||
|
||||
export interface GetHumanLanguageHierarchyResponse {
|
||||
message: string
|
||||
data: {
|
||||
category_id: number
|
||||
category_name: string
|
||||
sub_categories: HumanLanguageSubCategoryTree[]
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface GetSubCourseEntryAssessmentResponse {
|
||||
message: string
|
||||
data: QuestionSet | null
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user