integrate human language page with dedicated backend API
Switch the Human Language admin page to use CEFR-filtered backend endpoints and add typed API contracts for human language lessons. Made-with: Cursor
This commit is contained in:
parent
53a72bef2d
commit
e477595578
|
|
@ -47,6 +47,7 @@ import type {
|
|||
GetSubCoursePrerequisitesResponse,
|
||||
AddSubCoursePrerequisiteRequest,
|
||||
GetLearningPathResponse,
|
||||
GetHumanLanguageLessonsResponse,
|
||||
GetSubCourseEntryAssessmentResponse,
|
||||
ReorderItem,
|
||||
GetRatingsResponse,
|
||||
|
|
@ -286,6 +287,11 @@ export const removeSubCoursePrerequisite = (subCourseId: number, prerequisiteId:
|
|||
export const getLearningPath = (courseId: number) =>
|
||||
http.get<GetLearningPathResponse>(`/course-management/courses/${courseId}/learning-path`)
|
||||
|
||||
export const getHumanLanguageLessonsByCourse = (courseId: number, cefr_level: string) =>
|
||||
http.get<GetHumanLanguageLessonsResponse>(`/course-management/human-language/courses/${courseId}/lessons`, {
|
||||
params: { cefr_level },
|
||||
})
|
||||
|
||||
export const getSubCourseEntryAssessment = (subCourseId: number) =>
|
||||
http.get<GetSubCourseEntryAssessmentResponse>(
|
||||
`/question-sets/sub-courses/${subCourseId}/entry-assessment`,
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ 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, getLearningPath } from "../../api/courses.api"
|
||||
import type { Course, CourseCategory, LearningPathSubCourse } from "../../types/course.types"
|
||||
import { getCourseCategories, getCoursesByCategory, getHumanLanguageLessonsByCourse } from "../../api/courses.api"
|
||||
import type { Course, CourseCategory, HumanLanguageLesson } from "../../types/course.types"
|
||||
|
||||
const CEFR_LEVELS = ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3"] as const
|
||||
type CefrLevel = (typeof CEFR_LEVELS)[number]
|
||||
|
|
@ -18,7 +18,9 @@ export function HumanLanguagePage() {
|
|||
const [selectedCourseId, setSelectedCourseId] = useState<number | null>(null)
|
||||
const [selectedLevel, setSelectedLevel] = useState<CefrLevel | "ALL">("ALL")
|
||||
const [collapsedLevels, setCollapsedLevels] = useState<CefrLevel[]>([])
|
||||
const [subCourses, setSubCourses] = useState<LearningPathSubCourse[]>([])
|
||||
const [lessonsByLevel, setLessonsByLevel] = useState<Record<CefrLevel, HumanLanguageLesson[]>>(
|
||||
Object.fromEntries(CEFR_LEVELS.map((level) => [level, []])) as Record<CefrLevel, HumanLanguageLesson[]>,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
|
|
@ -57,35 +59,31 @@ export function HumanLanguagePage() {
|
|||
|
||||
useEffect(() => {
|
||||
if (!selectedCourseId) return
|
||||
const loadPath = async () => {
|
||||
const loadByLevels = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getLearningPath(selectedCourseId)
|
||||
setSubCourses(res.data?.data?.sub_courses ?? [])
|
||||
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
|
||||
}),
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
loadPath().catch(() => undefined)
|
||||
}, [selectedCourseId])
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const base = Object.fromEntries(CEFR_LEVELS.map((level) => [level, [] as LearningPathSubCourse[]])) as Record<
|
||||
CefrLevel,
|
||||
LearningPathSubCourse[]
|
||||
>
|
||||
for (const subCourse of subCourses) {
|
||||
const level = (subCourse.sub_level ?? "").toUpperCase() as CefrLevel
|
||||
if (CEFR_LEVELS.includes(level)) {
|
||||
base[level].push(subCourse)
|
||||
}
|
||||
}
|
||||
return base
|
||||
}, [subCourses])
|
||||
loadByLevels().catch(() => undefined)
|
||||
}, [selectedCourseId, selectedLevel])
|
||||
|
||||
const levelRows = useMemo(
|
||||
() => (selectedLevel === "ALL" ? CEFR_LEVELS : [selectedLevel]).map((level) => ({ level, rows: grouped[level] })),
|
||||
[grouped, selectedLevel],
|
||||
() => (selectedLevel === "ALL" ? CEFR_LEVELS : [selectedLevel]).map((level) => ({ level, rows: lessonsByLevel[level] })),
|
||||
[lessonsByLevel, selectedLevel],
|
||||
)
|
||||
|
||||
const toggleLevel = (level: CefrLevel) => {
|
||||
|
|
@ -194,14 +192,14 @@ export function HumanLanguagePage() {
|
|||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-grayScale-500">No lessons found for this level.</p>
|
||||
) : (
|
||||
rows.map((subCourse) => (
|
||||
<div key={subCourse.id} className="rounded-xl border border-grayScale-200 bg-white p-3">
|
||||
<p className="text-sm font-semibold text-grayScale-900">{subCourse.title}</p>
|
||||
rows.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">
|
||||
{subCourse.videos.length} lesson video(s) • {subCourse.practices.length} practice(s)
|
||||
{lesson.video_count} lesson video(s) • {lesson.practice_count} practice(s)
|
||||
</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
{subCourse.videos.map((video) => (
|
||||
{lesson.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}
|
||||
|
|
|
|||
|
|
@ -673,6 +673,33 @@ export interface GetLearningPathResponse {
|
|||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface HumanLanguageLesson {
|
||||
id: number
|
||||
course_id: number
|
||||
title: string
|
||||
description?: string | null
|
||||
thumbnail?: string | null
|
||||
display_order: number
|
||||
level: string
|
||||
video_count: number
|
||||
practice_count: number
|
||||
videos: LearningPathVideo[]
|
||||
practices: LearningPathPractice[]
|
||||
}
|
||||
|
||||
export interface GetHumanLanguageLessonsResponse {
|
||||
message: string
|
||||
data: {
|
||||
course_id: number
|
||||
course_title: string
|
||||
cefr_level: string
|
||||
lessons: HumanLanguageLesson[]
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface GetSubCourseEntryAssessmentResponse {
|
||||
message: string
|
||||
data: QuestionSet | null
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user