Compare commits
6 Commits
53a72bef2d
...
ee2dbc5792
| Author | SHA1 | Date | |
|---|---|---|---|
| ee2dbc5792 | |||
| 547e2c7970 | |||
| 6cba475145 | |||
| 0763b77d66 | |||
| 882db5444d | |||
| e477595578 |
|
|
@ -47,6 +47,9 @@ import type {
|
||||||
GetSubCoursePrerequisitesResponse,
|
GetSubCoursePrerequisitesResponse,
|
||||||
AddSubCoursePrerequisiteRequest,
|
AddSubCoursePrerequisiteRequest,
|
||||||
GetLearningPathResponse,
|
GetLearningPathResponse,
|
||||||
|
GetHumanLanguageLessonsResponse,
|
||||||
|
GetHumanLanguageHierarchyResponse,
|
||||||
|
CreateHumanLanguageLessonRequest,
|
||||||
GetSubCourseEntryAssessmentResponse,
|
GetSubCourseEntryAssessmentResponse,
|
||||||
ReorderItem,
|
ReorderItem,
|
||||||
GetRatingsResponse,
|
GetRatingsResponse,
|
||||||
|
|
@ -286,6 +289,17 @@ export const removeSubCoursePrerequisite = (subCourseId: number, prerequisiteId:
|
||||||
export const getLearningPath = (courseId: number) =>
|
export const getLearningPath = (courseId: number) =>
|
||||||
http.get<GetLearningPathResponse>(`/course-management/courses/${courseId}/learning-path`)
|
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 getHumanLanguageHierarchy = () =>
|
||||||
|
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`,
|
||||||
|
|
|
||||||
|
|
@ -41,12 +41,12 @@ export function AppLayout() {
|
||||||
<div className="flex items-center justify-center gap-1.5 text-xs text-grayScale-400">
|
<div className="flex items-center justify-center gap-1.5 text-xs text-grayScale-400">
|
||||||
<span>Powered by</span>
|
<span>Powered by</span>
|
||||||
<a
|
<a
|
||||||
href="https://tech.yaltopia.com"
|
href="https://yimaruacademy.com"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-semibold text-brand-500 transition-colors hover:text-brand-600"
|
className="font-semibold text-brand-500 transition-colors hover:text-brand-600"
|
||||||
>
|
>
|
||||||
Yaltopia Tech
|
Yimaru Academy
|
||||||
</a>
|
</a>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>© {new Date().getFullYear()}</span>
|
<span>© {new Date().getFullYear()}</span>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ const tabs = [
|
||||||
{ label: "Courses", to: "/content/courses" },
|
{ label: "Courses", to: "/content/courses" },
|
||||||
{ label: "Human Language", to: "/content/human-language" },
|
{ label: "Human Language", to: "/content/human-language" },
|
||||||
{ label: "Flows", to: "/content/flows" },
|
{ label: "Flows", to: "/content/flows" },
|
||||||
{ label: "Speaking", to: "/content/speaking" },
|
|
||||||
{ label: "Practice", to: "/content/practices" },
|
{ label: "Practice", to: "/content/practices" },
|
||||||
{ label: "Questions", to: "/content/questions" },
|
{ label: "Questions", to: "/content/questions" },
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,97 +1,152 @@
|
||||||
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 { getCourseCategories, getCoursesByCategory, getLearningPath } from "../../api/courses.api"
|
import { createHumanLanguageLesson, getHumanLanguageHierarchy } from "../../api/courses.api"
|
||||||
import type { Course, CourseCategory, LearningPathSubCourse } 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]
|
||||||
|
|
||||||
export function HumanLanguagePage() {
|
export function HumanLanguagePage() {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [categories, setCategories] = useState<CourseCategory[]>([])
|
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||||
const [courses, setCourses] = useState<Course[]>([])
|
const [subCategories, setSubCategories] = useState<HumanLanguageSubCategoryTree[]>([])
|
||||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null)
|
const [selectedSubCategoryId, setSelectedSubCategoryId] = useState<number | "ALL">("ALL")
|
||||||
const [selectedCourseId, setSelectedCourseId] = useState<number | null>(null)
|
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 [subCourses, setSubCourses] = useState<LearningPathSubCourse[]>([])
|
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 loadCategories = async () => {
|
const run = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await getCourseCategories()
|
await loadHierarchy()
|
||||||
const items = res.data?.data?.categories ?? []
|
|
||||||
setCategories(items)
|
|
||||||
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))
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadCategories().catch(() => undefined)
|
run().catch(() => undefined)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
const filteredSubCategories = useMemo(
|
||||||
if (!selectedCategoryId) return
|
() =>
|
||||||
const loadCourses = async () => {
|
selectedSubCategoryId === "ALL"
|
||||||
setLoading(true)
|
? subCategories
|
||||||
try {
|
: subCategories.filter((s) => s.sub_category_id === selectedSubCategoryId),
|
||||||
const res = await getCoursesByCategory(selectedCategoryId)
|
[subCategories, selectedSubCategoryId],
|
||||||
const items = res.data?.data?.courses ?? []
|
)
|
||||||
setCourses(items)
|
|
||||||
setSelectedCourseId(items[0]?.id ?? null)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadCourses().catch(() => undefined)
|
|
||||||
}, [selectedCategoryId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
const availableCourses = useMemo(() => {
|
||||||
if (!selectedCourseId) return
|
return filteredSubCategories.flatMap((s) => s.courses)
|
||||||
const loadPath = async () => {
|
}, [filteredSubCategories])
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await getLearningPath(selectedCourseId)
|
|
||||||
setSubCourses(res.data?.data?.sub_courses ?? [])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadPath().catch(() => undefined)
|
|
||||||
}, [selectedCourseId])
|
|
||||||
|
|
||||||
const grouped = useMemo(() => {
|
const selectedCourses = useMemo(
|
||||||
const base = Object.fromEntries(CEFR_LEVELS.map((level) => [level, [] as LearningPathSubCourse[]])) as Record<
|
() =>
|
||||||
CefrLevel,
|
selectedCourseId === "ALL"
|
||||||
LearningPathSubCourse[]
|
? availableCourses
|
||||||
>
|
: availableCourses.filter((c) => c.course_id === selectedCourseId),
|
||||||
for (const subCourse of subCourses) {
|
[availableCourses, selectedCourseId],
|
||||||
const level = (subCourse.sub_level ?? "").toUpperCase() as CefrLevel
|
|
||||||
if (CEFR_LEVELS.includes(level)) {
|
|
||||||
base[level].push(subCourse)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return base
|
|
||||||
}, [subCourses])
|
|
||||||
|
|
||||||
const levelRows = useMemo(
|
|
||||||
() => (selectedLevel === "ALL" ? CEFR_LEVELS : [selectedLevel]).map((level) => ({ level, rows: grouped[level] })),
|
|
||||||
[grouped, selectedLevel],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggleLevel = (level: CefrLevel) => {
|
const toggleLevel = (level: CefrLevel) => {
|
||||||
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">
|
||||||
|
|
@ -114,15 +169,18 @@ export function HumanLanguagePage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">Category</label>
|
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">Subcategory</label>
|
||||||
<select
|
<select
|
||||||
className="h-10 w-full rounded-md border border-grayScale-200 bg-white px-3 text-sm"
|
className="h-10 w-full rounded-md border border-grayScale-200 bg-white px-3 text-sm"
|
||||||
value={selectedCategoryId ?? ""}
|
value={selectedSubCategoryId}
|
||||||
onChange={(e) => setSelectedCategoryId(Number(e.target.value))}
|
onChange={(e) =>
|
||||||
|
setSelectedSubCategoryId(e.target.value === "ALL" ? "ALL" : Number(e.target.value))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{categories.map((category) => (
|
<option value="ALL">All subcategories</option>
|
||||||
<option key={category.id} value={category.id}>
|
{subCategories.map((subCategory) => (
|
||||||
{category.name}
|
<option key={subCategory.sub_category_id} value={subCategory.sub_category_id}>
|
||||||
|
{subCategory.sub_category_name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -131,12 +189,15 @@ export function HumanLanguagePage() {
|
||||||
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">Course</label>
|
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">Course</label>
|
||||||
<select
|
<select
|
||||||
className="h-10 w-full rounded-md border border-grayScale-200 bg-white px-3 text-sm"
|
className="h-10 w-full rounded-md border border-grayScale-200 bg-white px-3 text-sm"
|
||||||
value={selectedCourseId ?? ""}
|
value={selectedCourseId}
|
||||||
onChange={(e) => setSelectedCourseId(Number(e.target.value))}
|
onChange={(e) =>
|
||||||
|
setSelectedCourseId(e.target.value === "ALL" ? "ALL" : Number(e.target.value))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{courses.map((course) => (
|
<option value="ALL">All courses</option>
|
||||||
<option key={course.id} value={course.id}>
|
{availableCourses.map((course) => (
|
||||||
{course.title}
|
<option key={course.course_id} value={course.course_id}>
|
||||||
|
{course.course_name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -159,9 +220,9 @@ export function HumanLanguagePage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{selectedCategoryId && selectedCourseId ? (
|
{categoryId && selectedCourseId !== "ALL" ? (
|
||||||
<div className="flex justify-end">
|
<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>
|
<Button variant="outline">Open detailed management</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -174,7 +235,16 @@ export function HumanLanguagePage() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{levelRows.map(({ level, rows }) => (
|
{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 ?? [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return (
|
||||||
<Card key={level} className="overflow-hidden border-grayScale-200/80 shadow-sm">
|
<Card key={level} className="overflow-hidden border-grayScale-200/80 shadow-sm">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -185,28 +255,90 @@ export function HumanLanguagePage() {
|
||||||
{collapsedLevels.includes(level) ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
{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="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">
|
||||||
{rows.length} unit(s)
|
{modulesByCourse.reduce((sum, entry) => sum + entry.modules.length, 0)} module(s)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{!collapsedLevels.includes(level) ? (
|
{!collapsedLevels.includes(level) ? (
|
||||||
<CardContent className="space-y-3 p-4">
|
<CardContent className="space-y-3 p-4">
|
||||||
{rows.length === 0 ? (
|
{modulesByCourse.length === 0 ? (
|
||||||
<p className="text-sm text-grayScale-500">No lessons found for this level.</p>
|
<p className="text-sm text-grayScale-500">No lessons found for this level.</p>
|
||||||
) : (
|
) : (
|
||||||
rows.map((subCourse) => (
|
modulesByCourse.map((entry) => (
|
||||||
<div key={subCourse.id} className="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-grayScale-900">{subCourse.title}</p>
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="mt-1 text-xs text-grayScale-500">
|
<p className="text-sm font-semibold text-brand-700">{entry.course.course_name}</p>
|
||||||
{subCourse.videos.length} lesson video(s) • {subCourse.practices.length} practice(s)
|
<Button
|
||||||
</p>
|
size="sm"
|
||||||
<div className="mt-2 space-y-1">
|
variant="outline"
|
||||||
{subCourse.videos.map((video) => (
|
onClick={() => handleCreateModule(entry.course.course_id, level, entry.modules)}
|
||||||
<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">
|
disabled={creatingKey === `module-${entry.course.course_id}-${level}`}
|
||||||
<BookOpen className="h-3.5 w-3.5" />
|
>
|
||||||
{video.title}
|
{creatingKey === `module-${entry.course.course_id}-${level}` ? (
|
||||||
</div>
|
<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">
|
||||||
|
{entry.modules.length === 0 ? (
|
||||||
|
<p className="text-xs text-grayScale-500">No modules yet. Use “Add Module” to start.</p>
|
||||||
|
) : (
|
||||||
|
entry.modules.map((module) => (
|
||||||
|
<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">
|
||||||
|
<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) => (
|
||||||
|
<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/${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">
|
||||||
|
{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>
|
</div>
|
||||||
))
|
))
|
||||||
|
|
@ -214,7 +346,8 @@ export function HumanLanguagePage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
) : null}
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -673,6 +673,84 @@ export interface GetLearningPathResponse {
|
||||||
metadata: unknown
|
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 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 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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user