add module and sub-module creation actions in human language UI

Provide incremental Module-N and Module-N.M creation buttons, plus direct sub-module actions to manage lesson media and practice/audio question creation.

Made-with: Cursor
This commit is contained in:
Yared Yemane 2026-04-07 06:31:21 -07:00
parent 0763b77d66
commit 6cba475145
3 changed files with 157 additions and 11 deletions

View File

@ -49,6 +49,7 @@ import type {
GetLearningPathResponse, GetLearningPathResponse,
GetHumanLanguageLessonsResponse, GetHumanLanguageLessonsResponse,
GetHumanLanguageHierarchyResponse, GetHumanLanguageHierarchyResponse,
CreateHumanLanguageLessonRequest,
GetSubCourseEntryAssessmentResponse, GetSubCourseEntryAssessmentResponse,
ReorderItem, ReorderItem,
GetRatingsResponse, GetRatingsResponse,
@ -296,6 +297,9 @@ export const getHumanLanguageLessonsByCourse = (courseId: number, cefr_level: st
export const getHumanLanguageHierarchy = () => export const getHumanLanguageHierarchy = () =>
http.get<GetHumanLanguageHierarchyResponse>("/course-management/human-language/hierarchy") http.get<GetHumanLanguageHierarchyResponse>("/course-management/human-language/hierarchy")
export const createHumanLanguageLesson = (data: CreateHumanLanguageLessonRequest) =>
http.post("/course-management/human-language/lessons", data)
export const getSubCourseEntryAssessment = (subCourseId: number) => export const getSubCourseEntryAssessment = (subCourseId: number) =>
http.get<GetSubCourseEntryAssessmentResponse>( http.get<GetSubCourseEntryAssessmentResponse>(
`/question-sets/sub-courses/${subCourseId}/entry-assessment`, `/question-sets/sub-courses/${subCourseId}/entry-assessment`,

View File

@ -1,11 +1,12 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { BookOpen, ChevronDown, ChevronRight, Languages } from "lucide-react" import { BookOpen, ChevronDown, ChevronRight, Languages, Loader2, Plus } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { SpinnerIcon } from "../../components/ui/spinner-icon" import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { getHumanLanguageHierarchy } from "../../api/courses.api" import { createHumanLanguageLesson, getHumanLanguageHierarchy } from "../../api/courses.api"
import type { HumanLanguageCourseTree, HumanLanguageSubCategoryTree } from "../../types/course.types" import type { HumanLanguageCourseTree, HumanLanguageSubCategoryTree } from "../../types/course.types"
import { toast } from "sonner"
const CEFR_LEVELS = ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3"] as const const CEFR_LEVELS = ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3"] as const
type CefrLevel = (typeof CEFR_LEVELS)[number] type CefrLevel = (typeof CEFR_LEVELS)[number]
@ -18,20 +19,30 @@ export function HumanLanguagePage() {
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<CefrLevel[]>([])
const [creatingKey, setCreatingKey] = useState<string | null>(null)
const loadHierarchy = async () => {
setLoading(true)
try {
const res = await getHumanLanguageHierarchy()
const data = res.data?.data
setCategoryId(data?.category_id ?? null)
setSubCategories(data?.sub_categories ?? [])
} finally {
setLoading(false)
}
}
useEffect(() => { useEffect(() => {
const loadHierarchy = async () => { const run = async () => {
setLoading(true) setLoading(true)
try { try {
const res = await getHumanLanguageHierarchy() await loadHierarchy()
const data = res.data?.data
setCategoryId(data?.category_id ?? null)
setSubCategories(data?.sub_categories ?? [])
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
loadHierarchy().catch(() => undefined) run().catch(() => undefined)
}, []) }, [])
const filteredSubCategories = useMemo( const filteredSubCategories = useMemo(
@ -58,6 +69,84 @@ export function HumanLanguagePage() {
setCollapsedLevels((prev) => (prev.includes(level) ? prev.filter((l) => l !== level) : [...prev, level])) setCollapsedLevels((prev) => (prev.includes(level) ? prev.filter((l) => l !== level) : [...prev, level]))
} }
const parseModuleNumber = (title: string): number | null => {
const match = title.match(/module-(\d+)/i)
if (!match) return null
const value = Number(match[1])
return Number.isFinite(value) ? value : null
}
const parseSubModuleNumber = (title: string): { module: number; sub: number } | null => {
const match = title.match(/(?:sub-)?module-(\d+)\.(\d+)/i)
if (!match) return null
const module = Number(match[1])
const sub = Number(match[2])
if (!Number.isFinite(module) || !Number.isFinite(sub)) return null
return { module, sub }
}
const handleCreateModule = async (courseId: number, level: string, modules: { title: string }[]) => {
const key = `module-${courseId}-${level}`
setCreatingKey(key)
try {
const maxExisting = modules
.map((m) => parseModuleNumber(m.title))
.filter((v): v is number => v !== null)
.reduce((acc, n) => Math.max(acc, n), 0)
const next = maxExisting + 1
const title = `Module-${next}`
await createHumanLanguageLesson({
course_id: courseId,
cefr_level: level,
title,
description: `${level} ${title}`,
})
toast.success(`${title} created`)
await loadHierarchy()
} catch (error) {
console.error("Failed to create module:", error)
toast.error("Failed to create module")
} finally {
setCreatingKey(null)
}
}
const handleCreateSubModule = async (
courseId: number,
level: string,
moduleTitle: string,
existingSubModules: { title: string }[],
) => {
const moduleNo = parseModuleNumber(moduleTitle)
if (!moduleNo) {
toast.error("Cannot derive module number from title")
return
}
const key = `submodule-${courseId}-${level}-${moduleNo}`
setCreatingKey(key)
try {
const maxExisting = existingSubModules
.map((s) => parseSubModuleNumber(s.title))
.filter((v): v is { module: number; sub: number } => v !== null && v.module === moduleNo)
.reduce((acc, item) => Math.max(acc, item.sub), 0)
const next = maxExisting + 1
const title = `Module-${moduleNo}.${next}`
await createHumanLanguageLesson({
course_id: courseId,
cefr_level: level,
title,
description: `${level} ${title}`,
})
toast.success(`Sub-module ${moduleNo}.${next} created`)
await loadHierarchy()
} catch (error) {
console.error("Failed to create sub-module:", error)
toast.error("Failed to create sub-module")
} finally {
setCreatingKey(null)
}
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="rounded-2xl border border-grayScale-200 bg-gradient-to-r from-white to-brand-50/30 p-5 shadow-sm"> <div className="rounded-2xl border border-grayScale-200 bg-gradient-to-r from-white to-brand-50/30 p-5 shadow-sm">
@ -178,14 +267,58 @@ export function HumanLanguagePage() {
) : ( ) : (
modulesByCourse.map((entry) => ( modulesByCourse.map((entry) => (
<div key={entry.course.course_id} className="space-y-2 rounded-xl border border-grayScale-200 bg-white p-3"> <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="flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-brand-700">{entry.course.course_name}</p>
<Button
size="sm"
variant="outline"
onClick={() => handleCreateModule(entry.course.course_id, level, entry.modules)}
disabled={creatingKey === `module-${entry.course.course_id}-${level}`}
>
{creatingKey === `module-${entry.course.course_id}-${level}` ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="h-3.5 w-3.5" />
)}
Add Module
</Button>
</div>
<div className="space-y-2"> <div className="space-y-2">
{entry.modules.map((module) => ( {entry.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">
<p className="text-sm font-semibold text-grayScale-900">Module: {module.title}</p> <div className="flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-grayScale-900">Module: {module.title}</p>
<Button
size="sm"
variant="outline"
onClick={() =>
handleCreateSubModule(entry.course.course_id, level, module.title, module.sub_modules)
}
disabled={creatingKey === `submodule-${entry.course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}`}
>
{creatingKey === `submodule-${entry.course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}` ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="h-3.5 w-3.5" />
)}
Add Sub-module
</Button>
</div>
{module.sub_modules.map((subModule) => ( {module.sub_modules.map((subModule) => (
<div key={subModule.id} className="mt-2 rounded-md border border-grayScale-100 bg-white p-2"> <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="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/${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`}>
<Button size="sm">Add practice/audio questions</Button>
</Link>
</div>
) : null}
</div>
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{subModule.videos.map((video) => ( {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"> <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">

View File

@ -742,6 +742,15 @@ export interface GetHumanLanguageHierarchyResponse {
metadata: unknown metadata: unknown
} }
export interface CreateHumanLanguageLessonRequest {
course_id: number
title: string
description?: string
thumbnail?: string
display_order?: number
cefr_level: string
}
export interface GetSubCourseEntryAssessmentResponse { export interface GetSubCourseEntryAssessmentResponse {
message: string message: string
data: QuestionSet | null data: QuestionSet | null