From 24b5a0d7ee082b024e8abb7351c2ad202792f2b8 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 14 Apr 2026 05:09:28 -0700 Subject: [PATCH 01/58] fix human language hierarchy rendering after create Normalize flat hierarchy rows from backend into the nested shape expected by the content-management page so new sub-categories and courses show immediately. Made-with: Cursor --- src/api/courses.api.ts | 180 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 179 insertions(+), 1 deletion(-) diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index 2565f64..c93de0f 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -507,7 +507,185 @@ export const getHumanLanguageLessonsByCourse = (courseId: number, cefr_level: st }) export const getHumanLanguageHierarchy = () => - http.get("/course-management/hierarchy") + http.get("/course-management/hierarchy").then(async (res) => { + const payload = res.data?.data as unknown + if (payload && typeof payload === "object" && !Array.isArray(payload) && "sub_categories" in payload) { + return res + } + + const rows: UnifiedHierarchyRow[] = Array.isArray(payload) ? payload : [] + const categoryMap = new Map< + number, + { + category_id: number + category_name: string + sub_categories: Map< + number, + { + sub_category_id: number + sub_category_name: string + courses: Map< + number, + { + course_id: number + course_name: string + } + > + } + > + } + >() + + rows.forEach((row) => { + const categoryId = Number(row.category_id) + if (!Number.isFinite(categoryId)) return + + if (!categoryMap.has(categoryId)) { + categoryMap.set(categoryId, { + category_id: categoryId, + category_name: row.category_name ?? "", + sub_categories: new Map(), + }) + } + + if (!row.sub_category_id) return + const subCategoryId = Number(row.sub_category_id) + if (!Number.isFinite(subCategoryId)) return + + const categoryNode = categoryMap.get(categoryId)! + if (!categoryNode.sub_categories.has(subCategoryId)) { + categoryNode.sub_categories.set(subCategoryId, { + sub_category_id: subCategoryId, + sub_category_name: row.sub_category_name ?? "", + courses: new Map(), + }) + } + + if (!row.course_id) return + const courseId = Number(row.course_id) + if (!Number.isFinite(courseId)) return + + const subCategoryNode = categoryNode.sub_categories.get(subCategoryId)! + if (!subCategoryNode.courses.has(courseId)) { + subCategoryNode.courses.set(courseId, { + course_id: courseId, + course_name: row.course_title ?? "", + }) + } + }) + + const selectedCategory = + Array.from(categoryMap.values()).find((c) => c.category_name.toLowerCase().includes("human")) ?? + Array.from(categoryMap.values())[0] + + if (!selectedCategory) { + return { + ...res, + data: { + ...res.data, + data: { + category_id: 0, + category_name: "", + sub_categories: [], + }, + }, + } as unknown as { data: GetHumanLanguageHierarchyResponse } + } + + const courses = Array.from(selectedCategory.sub_categories.values()).flatMap((sub) => + Array.from(sub.courses.values()).map((course) => ({ sub_category_id: sub.sub_category_id, course })), + ) + + const hierarchyResponses = await Promise.all( + courses.map(({ course }) => + http + .get(`/course-management/courses/${course.course_id}/hierarchy`) + .then((courseRes) => ({ course_id: course.course_id, rows: (courseRes.data?.data ?? []) as CourseHierarchyRow[] })) + .catch(() => ({ course_id: course.course_id, rows: [] as CourseHierarchyRow[] })), + ), + ) + + const hierarchyByCourse = new Map( + hierarchyResponses.map((h) => [h.course_id, h.rows]), + ) + + const subCategories = Array.from(selectedCategory.sub_categories.values()).map((sub) => ({ + sub_category_id: sub.sub_category_id, + sub_category_name: sub.sub_category_name, + courses: Array.from(sub.courses.values()).map((course) => { + const levelMap = new Map< + string, + { + level: string + modules: Map< + number, + { + id: number + title: string + sub_modules: Map + } + > + } + >() + + ;(hierarchyByCourse.get(course.course_id) ?? []).forEach((row) => { + if (!row.level_id || !row.cefr_level) return + const levelKey = String(row.cefr_level).toUpperCase() + if (!levelMap.has(levelKey)) { + levelMap.set(levelKey, { level: levelKey, modules: new Map() }) + } + + if (!row.module_id) return + const levelNode = levelMap.get(levelKey)! + const moduleId = Number(row.module_id) + if (!levelNode.modules.has(moduleId)) { + levelNode.modules.set(moduleId, { + id: moduleId, + title: row.module_title ?? "", + sub_modules: new Map(), + }) + } + + if (!row.sub_module_id) return + const moduleNode = levelNode.modules.get(moduleId)! + const subModuleId = Number(row.sub_module_id) + if (!moduleNode.sub_modules.has(subModuleId)) { + moduleNode.sub_modules.set(subModuleId, { + id: subModuleId, + title: row.sub_module_title ?? "", + videos: [], + practices: [], + }) + } + }) + + return { + course_id: course.course_id, + course_name: course.course_name, + levels: Array.from(levelMap.values()).map((levelNode) => ({ + level: levelNode.level, + modules: Array.from(levelNode.modules.values()).map((moduleNode) => ({ + id: moduleNode.id, + title: moduleNode.title, + sub_modules: Array.from(moduleNode.sub_modules.values()), + })), + })), + } + }), + })) + + return { + ...res, + data: { + ...res.data, + data: { + category_id: selectedCategory.category_id, + category_name: selectedCategory.category_name, + sub_categories: subCategories, + }, + }, + } as unknown as { data: GetHumanLanguageHierarchyResponse } + }) export const createHumanLanguageLesson = (data: CreateHumanLanguageLessonRequest) => http From 5206fb2e1a0529675783db9d4ca1e6832a948ee4 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 14 Apr 2026 05:23:44 -0700 Subject: [PATCH 02/58] fix human language quick-create path creation Create a real sub-category under Human Language and pass sub_category_id when creating the course so the new path renders immediately in the hierarchy panel. Made-with: Cursor --- src/pages/content-management/HumanLanguagePage.tsx | 14 ++++++++++++-- src/types/course.types.ts | 1 + 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/pages/content-management/HumanLanguagePage.tsx b/src/pages/content-management/HumanLanguagePage.tsx index d6e2ec9..04a71a2 100644 --- a/src/pages/content-management/HumanLanguagePage.tsx +++ b/src/pages/content-management/HumanLanguagePage.tsx @@ -628,10 +628,20 @@ export function HumanLanguagePage() { if (!effectiveCategoryId) { throw new Error("Missing human language category id") } - const title = `${quickSubCategoryName.trim()} - ${quickCourseName.trim()}` + + const createdSubCategory = await createCourseCategory({ + name: quickSubCategoryName.trim(), + parent_id: effectiveCategoryId, + }) + const subCategoryId = createdSubCategory.data?.data?.id + if (!subCategoryId) { + throw new Error("Failed to create subcategory") + } + await createCourse({ category_id: effectiveCategoryId, - title, + sub_category_id: Number(subCategoryId), + title: quickCourseName.trim(), description: `${quickSubCategoryName.trim()} / ${quickCourseName.trim()}`, }) toast.success("Subcategory/course path created") diff --git a/src/types/course.types.ts b/src/types/course.types.ts index 4e08ba1..cb33048 100644 --- a/src/types/course.types.ts +++ b/src/types/course.types.ts @@ -45,6 +45,7 @@ export interface GetCoursesResponse { export interface CreateCourseRequest { category_id: number + sub_category_id?: number | null title: string description: string } From da6754e6f5e71c70b5d33d45064701b15e56ca80 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 14 Apr 2026 05:31:57 -0700 Subject: [PATCH 03/58] fix human language category selection when duplicates exist Prefer the most populated Human Language category (then latest id fallback) so sub-categories and courses render correctly when multiple same-name categories are present. Made-with: Cursor --- src/api/courses.api.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index c93de0f..1204312 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -574,9 +574,21 @@ export const getHumanLanguageHierarchy = () => } }) - const selectedCategory = - Array.from(categoryMap.values()).find((c) => c.category_name.toLowerCase().includes("human")) ?? - Array.from(categoryMap.values())[0] + const categories = Array.from(categoryMap.values()) + const humanLanguageCandidates = categories.filter((c) => c.category_name.toLowerCase().includes("human")) + + const selectedCategory = (humanLanguageCandidates.length ? humanLanguageCandidates : categories).sort((a, b) => { + const aSubCategoryCount = a.sub_categories.size + const bSubCategoryCount = b.sub_categories.size + if (aSubCategoryCount !== bSubCategoryCount) return bSubCategoryCount - aSubCategoryCount + + const aCourseCount = Array.from(a.sub_categories.values()).reduce((sum, sub) => sum + sub.courses.size, 0) + const bCourseCount = Array.from(b.sub_categories.values()).reduce((sum, sub) => sum + sub.courses.size, 0) + if (aCourseCount !== bCourseCount) return bCourseCount - aCourseCount + + // If tied on richness, pick the latest category id. + return b.category_id - a.category_id + })[0] if (!selectedCategory) { return { From 78111f161f026cd5a891447c63a86ed382b48d52 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 14 Apr 2026 05:36:54 -0700 Subject: [PATCH 04/58] fix category and course loading with duplicate names Normalize duplicate category names in hierarchy responses and aggregate courses across same-name category IDs so Human Language and Courses tabs consistently show data. Made-with: Cursor --- src/api/courses.api.ts | 84 +++++++++++++++++++++++++++++++++++------- 1 file changed, 71 insertions(+), 13 deletions(-) diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index 1204312..452bb6b 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -81,7 +81,10 @@ type CourseHierarchyRow = { export const getCourseCategories = () => http.get("/course-management/hierarchy").then((res) => { const rows: UnifiedHierarchyRow[] = res.data?.data ?? [] - const categoriesMap = new Map() + const categoriesMap = new Map< + number, + { id: number; name: string; is_active: boolean; created_at: string; subCategoryCount: number; courseCount: number } + >() rows.forEach((r) => { if (!categoriesMap.has(r.category_id)) { categoriesMap.set(r.category_id, { @@ -89,10 +92,50 @@ export const getCourseCategories = () => name: r.category_name, is_active: true, created_at: new Date().toISOString(), + subCategoryCount: 0, + courseCount: 0, }) } + const category = categoriesMap.get(r.category_id)! + if (r.sub_category_id) category.subCategoryCount += 1 + if (r.course_id) category.courseCount += 1 }) - const categories = Array.from(categoriesMap.values()) + + // Merge duplicate top-level category names by selecting the richest representative. + type CategoryAggregate = { + id: number + name: string + is_active: boolean + created_at: string + subCategoryCount: number + courseCount: number + } + const categoryByName = new Map() + Array.from(categoriesMap.values()).forEach((category) => { + const key = category.name.trim().toLowerCase() + const existing = categoryByName.get(key) + if (!existing) { + categoryByName.set(key, category) + return + } + if (category.subCategoryCount > existing.subCategoryCount) { + categoryByName.set(key, category) + return + } + if (category.subCategoryCount === existing.subCategoryCount && category.courseCount > existing.courseCount) { + categoryByName.set(key, category) + return + } + if ( + category.subCategoryCount === existing.subCategoryCount && + category.courseCount === existing.courseCount && + category.id > existing.id + ) { + categoryByName.set(key, category) + } + }) + + const categories = Array.from(categoryByName.values()).map(({ subCategoryCount, courseCount, ...category }) => category) return { ...res, data: { @@ -113,17 +156,32 @@ export const createCourseCategory = (data: CreateCourseCategoryRequest) => export const getCoursesByCategory = (categoryId: number) => http.get("/course-management/hierarchy").then((res) => { const rows: UnifiedHierarchyRow[] = res.data?.data ?? [] - const courses = rows - .filter((r) => r.category_id === categoryId && r.course_id) - .map((r) => ({ - id: Number(r.course_id), - category_id: r.category_id, - sub_category_id: r.sub_category_id ?? null, - title: r.course_title ?? "", - description: "", - thumbnail: "", - is_active: true, - })) + + const requestedCategoryRows = rows.filter((r) => r.category_id === categoryId) + const requestedCategoryName = requestedCategoryRows.find((r) => !!r.category_name)?.category_name?.trim().toLowerCase() + const relevantRows = requestedCategoryName + ? rows.filter((r) => r.category_name?.trim().toLowerCase() === requestedCategoryName) + : requestedCategoryRows + + const courseMap = new Map() + relevantRows + .filter((r) => r.course_id) + .forEach((r) => { + const id = Number(r.course_id) + if (!Number.isFinite(id)) return + if (courseMap.has(id)) return + courseMap.set(id, { + id, + category_id: r.category_id, + sub_category_id: r.sub_category_id ?? null, + title: r.course_title ?? "", + description: "", + thumbnail: "", + is_active: true, + }) + }) + const courses = Array.from(courseMap.values()) + return { ...res, data: { ...res.data, data: { courses, total_count: courses.length } }, From bfbdf0fc19392b43ff15527a2d7fb5b09673f58a Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 14 Apr 2026 05:45:35 -0700 Subject: [PATCH 05/58] add sub-category and course delete controls in human language page Wire delete actions and confirmation dialogs for selected sub-categories and courses, backed by the new sub-category delete API route. Made-with: Cursor --- src/api/courses.api.ts | 3 + .../content-management/HumanLanguagePage.tsx | 131 ++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index 452bb6b..314c817 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -153,6 +153,9 @@ export const createCourseCategory = (data: CreateCourseCategoryRequest) => ? http.post("/course-management/sub-categories", { category_id: data.parent_id, name: data.name }) : http.post("/course-management/categories", { name: data.name }) +export const deleteCourseSubCategory = (subCategoryId: number) => + http.delete(`/course-management/sub-categories/${subCategoryId}`) + export const getCoursesByCategory = (categoryId: number) => http.get("/course-management/hierarchy").then((res) => { const rows: UnifiedHierarchyRow[] = res.data?.data ?? [] diff --git a/src/pages/content-management/HumanLanguagePage.tsx b/src/pages/content-management/HumanLanguagePage.tsx index 04a71a2..48843d1 100644 --- a/src/pages/content-management/HumanLanguagePage.tsx +++ b/src/pages/content-management/HumanLanguagePage.tsx @@ -35,6 +35,8 @@ import { createCourse, createCourseCategory, createHumanLanguageLesson, + deleteCourse, + deleteCourseSubCategory, deleteQuestionSet, deleteQuestion, deleteSubModule, @@ -325,6 +327,10 @@ export function HumanLanguagePage() { const [quickCourseName, setQuickCourseName] = useState("") const [quickSearch, setQuickSearch] = useState("") const [quickCreating, setQuickCreating] = useState(false) + const [subCategoryTargetDelete, setSubCategoryTargetDelete] = useState<{ id: number; name: string } | null>(null) + const [courseTargetDelete, setCourseTargetDelete] = useState<{ id: number; name: string } | null>(null) + const [deletingSubCategory, setDeletingSubCategory] = useState(false) + const [deletingCourse, setDeletingCourse] = useState(false) const [deletingKey, setDeletingKey] = useState(null) /** Course IDs whose path body is collapsed (headers stay visible). */ const [collapsedPathIds, setCollapsedPathIds] = useState([]) @@ -656,6 +662,41 @@ export function HumanLanguagePage() { } } + const handleDeleteSelectedSubCategory = async () => { + if (!subCategoryTargetDelete) return + setDeletingSubCategory(true) + try { + await deleteCourseSubCategory(subCategoryTargetDelete.id) + toast.success("Sub-category deleted") + setSubCategoryTargetDelete(null) + setSelectedSubCategoryId("ALL") + setSelectedCourseId("ALL") + await loadHierarchy() + } catch (error) { + console.error("Failed to delete sub-category:", error) + toast.error("Failed to delete sub-category") + } finally { + setDeletingSubCategory(false) + } + } + + const handleDeleteSelectedCourse = async () => { + if (!courseTargetDelete) return + setDeletingCourse(true) + try { + await deleteCourse(courseTargetDelete.id) + toast.success("Course deleted") + setCourseTargetDelete(null) + setSelectedCourseId("ALL") + await loadHierarchy() + } catch (error) { + console.error("Failed to delete course:", error) + toast.error("Failed to delete course") + } finally { + setDeletingCourse(false) + } + } + const loadPracticeQuestionsIfNeeded = async (practiceId: number, forceRefresh = false) => { let skipFetch = false setPracticeQuestionsState((prev) => { @@ -1164,6 +1205,44 @@ export function HumanLanguagePage() { +
+ + + +
+ {loading ? (
@@ -2170,6 +2249,58 @@ export function HumanLanguagePage() { + !open && setSubCategoryTargetDelete(null)}> + + + Delete sub-category? + + {subCategoryTargetDelete + ? `This will permanently delete "${subCategoryTargetDelete.name}" and all courses under it.` + : ""} + + + + + + + + + + !open && setCourseTargetDelete(null)}> + + + Delete course? + + {courseTargetDelete + ? `This will permanently delete "${courseTargetDelete.name}" and all nested content under it.` + : ""} + + + + + + + + + { From 909a2f42bc6641189deb2c05d6961b53a132823c Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 14 Apr 2026 05:51:55 -0700 Subject: [PATCH 06/58] fix second module creation in human language paths Create additional modules/sub-modules directly under existing level and module IDs instead of re-running the lesson bootstrap flow, which prevented adding a second module. Made-with: Cursor --- src/api/courses.api.ts | 32 +++++++++++++++- .../content-management/HumanLanguagePage.tsx | 38 +++++++++++-------- src/types/course.types.ts | 1 + 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index 314c817..2b03358 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -689,6 +689,7 @@ export const getHumanLanguageHierarchy = () => const levelMap = new Map< string, { + level_id?: number level: string modules: Map< number, @@ -705,7 +706,7 @@ export const getHumanLanguageHierarchy = () => if (!row.level_id || !row.cefr_level) return const levelKey = String(row.cefr_level).toUpperCase() if (!levelMap.has(levelKey)) { - levelMap.set(levelKey, { level: levelKey, modules: new Map() }) + levelMap.set(levelKey, { level_id: Number(row.level_id), level: levelKey, modules: new Map() }) } if (!row.module_id) return @@ -736,6 +737,7 @@ export const getHumanLanguageHierarchy = () => course_id: course.course_id, course_name: course.course_name, levels: Array.from(levelMap.values()).map((levelNode) => ({ + level_id: levelNode.level_id, level: levelNode.level, modules: Array.from(levelNode.modules.values()).map((moduleNode) => ({ id: moduleNode.id, @@ -787,6 +789,34 @@ export const createHumanLanguageLesson = (data: CreateHumanLanguageLessonRequest }), ) +export const createModuleInLevel = ( + levelId: number, + title: string, + description: string, + displayOrder = 0, +) => + http.post("/course-management/modules", { + level_id: levelId, + title, + description, + display_order: displayOrder, + is_active: true, + }) + +export const createSubModuleInModule = ( + moduleId: number, + title: string, + description: string, + displayOrder = 0, +) => + http.post("/course-management/sub-modules", { + module_id: moduleId, + title, + description, + display_order: displayOrder, + is_active: true, + }) + export const getSubModuleEntryAssessment = (subModuleId: number) => http.get( `/question-sets/sub-courses/${subModuleId}/entry-assessment`, diff --git a/src/pages/content-management/HumanLanguagePage.tsx b/src/pages/content-management/HumanLanguagePage.tsx index 48843d1..92c980e 100644 --- a/src/pages/content-management/HumanLanguagePage.tsx +++ b/src/pages/content-management/HumanLanguagePage.tsx @@ -35,6 +35,8 @@ import { createCourse, createCourseCategory, createHumanLanguageLesson, + createModuleInLevel, + createSubModuleInModule, deleteCourse, deleteCourseSubCategory, deleteQuestionSet, @@ -502,7 +504,16 @@ export function HumanLanguagePage() { return { module, sub } } - const handleCreateModule = async (courseId: number, level: string, modules: { title: string }[]) => { + const handleCreateModule = async ( + courseId: number, + levelNode: HumanLanguageCourseTree["levels"][number] | undefined, + modules: { title: string }[], + ) => { + if (!levelNode?.level_id) { + toast.error("Cannot create module: missing level identifier") + return + } + const level = levelNode.level const key = `module-${courseId}-${level}` setCreatingKey(key) try { @@ -511,12 +522,7 @@ export function HumanLanguagePage() { .filter((v): v is number => v !== null && v > 0) const next = nextMissingPositive(usedNumbers) const title = `Module-${next}` - await createHumanLanguageLesson({ - course_id: courseId, - cefr_level: level, - title, - description: `${level} ${title}`, - }) + await createModuleInLevel(levelNode.level_id, title, `${level} ${title}`, next) toast.success(`${title} created`) await loadHierarchy() } catch (error) { @@ -530,6 +536,7 @@ export function HumanLanguagePage() { const handleCreateSubModule = async ( courseId: number, level: string, + moduleId: number, moduleTitle: string, existingSubModules: { title: string }[], ) => { @@ -547,12 +554,7 @@ export function HumanLanguagePage() { .map((item) => item.sub) const next = nextMissingPositive(usedNumbers) const title = `Module-${moduleNo}.${next}` - await createHumanLanguageLesson({ - course_id: courseId, - cefr_level: level, - title, - description: `${level} ${title}`, - }) + await createSubModuleInModule(moduleId, title, `${level} ${title}`, next) toast.success(`Sub-module ${moduleNo}.${next} created`) await loadHierarchy() } catch (error) { @@ -1399,7 +1401,7 @@ export function HumanLanguagePage() { size="sm" variant="outline" className="h-8 border-grayScale-200 bg-white text-xs hover:border-brand-200 hover:bg-brand-50/40" - onClick={() => handleCreateModule(course.course_id, level, modules)} + onClick={() => handleCreateModule(course.course_id, levelNode, modules)} disabled={creatingKey === `module-${course.course_id}-${level}`} > {creatingKey === `module-${course.course_id}-${level}` ? ( @@ -1441,7 +1443,13 @@ export function HumanLanguagePage() { variant="outline" className="h-8 border-grayScale-200 bg-white text-xs hover:border-brand-200 hover:bg-brand-50/40" onClick={() => - handleCreateSubModule(course.course_id, level, module.title, module.sub_modules) + handleCreateSubModule( + course.course_id, + level, + module.id, + module.title, + module.sub_modules, + ) } disabled={creatingKey === `submodule-${course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}`} > diff --git a/src/types/course.types.ts b/src/types/course.types.ts index cb33048..5b6c62d 100644 --- a/src/types/course.types.ts +++ b/src/types/course.types.ts @@ -729,6 +729,7 @@ export interface HumanLanguageModule { } export interface HumanLanguageLevelTree { + level_id?: number level: string modules: HumanLanguageModule[] } From fe3f254dfc28b526920280fcc1c2ea15edb76313 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 14 Apr 2026 05:58:51 -0700 Subject: [PATCH 07/58] fix level/module removal behavior and reduce creation popups Use module delete operations for level/module removal so removed levels disappear correctly, and suppress success toasts for module/sub-module creation to keep the flow cleaner and faster. Made-with: Cursor --- .../content-management/HumanLanguagePage.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/pages/content-management/HumanLanguagePage.tsx b/src/pages/content-management/HumanLanguagePage.tsx index 92c980e..ceff372 100644 --- a/src/pages/content-management/HumanLanguagePage.tsx +++ b/src/pages/content-management/HumanLanguagePage.tsx @@ -37,6 +37,7 @@ import { createHumanLanguageLesson, createModuleInLevel, createSubModuleInModule, + deleteModule, deleteCourse, deleteCourseSubCategory, deleteQuestionSet, @@ -185,6 +186,7 @@ type CefrLevel = (typeof CEFR_LEVELS)[number] type PendingRemove = { ids: number[] + target: "sub_module" | "module" key: string successMessage: string title: string @@ -523,7 +525,6 @@ export function HumanLanguagePage() { const next = nextMissingPositive(usedNumbers) const title = `Module-${next}` await createModuleInLevel(levelNode.level_id, title, `${level} ${title}`, next) - toast.success(`${title} created`) await loadHierarchy() } catch (error) { console.error("Failed to create module:", error) @@ -555,7 +556,6 @@ export function HumanLanguagePage() { const next = nextMissingPositive(usedNumbers) const title = `Module-${moduleNo}.${next}` await createSubModuleInModule(moduleId, title, `${level} ${title}`, next) - toast.success(`Sub-module ${moduleNo}.${next} created`) await loadHierarchy() } catch (error) { console.error("Failed to create sub-module:", error) @@ -572,12 +572,16 @@ export function HumanLanguagePage() { const executePendingRemove = async () => { if (!pendingRemove) return - const { ids, key, successMessage } = pendingRemove + const { ids, target, key, successMessage } = pendingRemove setPendingRemove(null) setDeletingKey(key) try { for (const id of ids) { - await deleteSubModule(id) + if (target === "module") { + await deleteModule(id) + } else { + await deleteSubModule(id) + } } toast.success(successMessage) await loadHierarchy() @@ -1357,7 +1361,7 @@ export function HumanLanguagePage() { const levelNode = course.levels.find((item) => item.level.toUpperCase() === level) const modules = levelNode?.modules ?? [] const levelKey = `${course.course_id}-${level}` - const levelRemoveIds = modules.flatMap((m) => m.sub_modules.map((s) => s.id)) + const levelRemoveIds = modules.map((m) => m.id) const canRemoveLevel = levelRemoveIds.length > 0 return (
@@ -1383,6 +1387,7 @@ export function HumanLanguagePage() { onClick={() => requestRemove({ ids: levelRemoveIds, + target: "module", key: `level-${course.course_id}-${level}`, successMessage: `Level ${level} removed`, title: `Remove level ${level}?`, @@ -1468,7 +1473,8 @@ export function HumanLanguagePage() { disabled={deletingKey === `module-${module.id}`} onClick={() => requestRemove({ - ids: module.sub_modules.map((s) => s.id), + ids: [module.id], + target: "module", key: `module-${module.id}`, successMessage: `Module ${module.title} removed`, title: `Remove ${module.title}?`, @@ -1541,6 +1547,7 @@ export function HumanLanguagePage() { onClick={() => requestRemove({ ids: [subModule.id], + target: "sub_module", key: `submodule-${subModule.id}`, successMessage: `Sub-module ${subModule.title} removed`, title: `Remove ${subModule.title}?`, From 51ac1ad81dede8bc088d66b0a2534aa8aed89199 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 14 Apr 2026 06:11:37 -0700 Subject: [PATCH 08/58] add new lesson quick action in human language panel Show a New lesson button in the lessons tab alongside the practice action so lesson creation is directly accessible from each sub-module panel. Made-with: Cursor --- src/pages/content-management/HumanLanguagePage.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/pages/content-management/HumanLanguagePage.tsx b/src/pages/content-management/HumanLanguagePage.tsx index ceff372..d746e9f 100644 --- a/src/pages/content-management/HumanLanguagePage.tsx +++ b/src/pages/content-management/HumanLanguagePage.tsx @@ -1611,6 +1611,20 @@ export function HumanLanguagePage() { New practice + ) : panelTab === "lessons" && categoryId ? ( + + + ) : null}
From e5d1ba9b8dd29563996389f22e6e9b47cbea8224 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 14 Apr 2026 06:22:36 -0700 Subject: [PATCH 09/58] add category delete UI and streamline module creation loading Add deletion controls for course categories and avoid full-page loading overlay during module/sub-module creation so button-level spinners remain the only loading indicator. Made-with: Cursor --- src/api/courses.api.ts | 3 + .../content-management/CourseCategoryPage.tsx | 73 +++++++++++++++++-- .../content-management/HumanLanguagePage.tsx | 10 +-- 3 files changed, 73 insertions(+), 13 deletions(-) diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index 2b03358..98d4752 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -153,6 +153,9 @@ export const createCourseCategory = (data: CreateCourseCategoryRequest) => ? http.post("/course-management/sub-categories", { category_id: data.parent_id, name: data.name }) : http.post("/course-management/categories", { name: data.name }) +export const deleteCourseCategory = (categoryId: number) => + http.delete(`/course-management/categories/${categoryId}`) + export const deleteCourseSubCategory = (subCategoryId: number) => http.delete(`/course-management/sub-categories/${subCategoryId}`) diff --git a/src/pages/content-management/CourseCategoryPage.tsx b/src/pages/content-management/CourseCategoryPage.tsx index 6cbb4f8..08f04b5 100644 --- a/src/pages/content-management/CourseCategoryPage.tsx +++ b/src/pages/content-management/CourseCategoryPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react" import { Link } from "react-router-dom" -import { FolderOpen, RefreshCw, BookOpen, Plus } from "lucide-react" +import { FolderOpen, RefreshCw, BookOpen, Plus, Trash2 } from "lucide-react" import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg" import alertSrc from "../../assets/Alert.svg" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" @@ -11,10 +11,11 @@ import { Dialog, DialogContent, DialogDescription, + DialogFooter, DialogHeader, DialogTitle, } from "../../components/ui/dialog" -import { getCourseCategories, createCourseCategory } from "../../api/courses.api" +import { getCourseCategories, createCourseCategory, deleteCourseCategory } from "../../api/courses.api" import type { CourseCategory } from "../../types/course.types" import { toast } from "sonner" @@ -29,6 +30,8 @@ export function CourseCategoryPage() { const [newSubCategoryName, setNewSubCategoryName] = useState("") const [pendingSubCategories, setPendingSubCategories] = useState([]) const [searchQuery, setSearchQuery] = useState("") + const [deleteTarget, setDeleteTarget] = useState(null) + const [deleting, setDeleting] = useState(false) const fetchCategories = async () => { setLoading(true) @@ -164,12 +167,26 @@ export function CourseCategoryPage() { - - View Sub-categories - - → +
+ + View Sub-categories + + → + - + +
@@ -335,7 +352,7 @@ export function CourseCategoryPage() { if (createdCategoryId && pendingSubCategories.length > 0) { await Promise.all( pendingSubCategories.map((subName) => - createCourseCategory({ name: subName }), + createCourseCategory({ name: subName, parent_id: createdCategoryId }), ), ) } @@ -371,6 +388,46 @@ export function CourseCategoryPage() { + + !open && setDeleteTarget(null)}> + + + Delete category? + + {deleteTarget + ? `This will permanently delete "${deleteTarget.name}" and all linked sub-categories/courses.` + : ""} + + + + + + + + ) } diff --git a/src/pages/content-management/HumanLanguagePage.tsx b/src/pages/content-management/HumanLanguagePage.tsx index d746e9f..64a4087 100644 --- a/src/pages/content-management/HumanLanguagePage.tsx +++ b/src/pages/content-management/HumanLanguagePage.tsx @@ -380,8 +380,8 @@ export function HumanLanguagePage() { label?: string, ) => - const loadHierarchy = async () => { - setLoading(true) + const loadHierarchy = async (showLoading = true) => { + if (showLoading) setLoading(true) try { const res = await getHumanLanguageHierarchy() const data = res.data?.data @@ -404,7 +404,7 @@ export function HumanLanguagePage() { setCollapsedModuleIds(moduleIds) setCollapsedSubModuleIds(subModuleIds) } finally { - setLoading(false) + if (showLoading) setLoading(false) } } @@ -525,7 +525,7 @@ export function HumanLanguagePage() { const next = nextMissingPositive(usedNumbers) const title = `Module-${next}` await createModuleInLevel(levelNode.level_id, title, `${level} ${title}`, next) - await loadHierarchy() + await loadHierarchy(false) } catch (error) { console.error("Failed to create module:", error) toast.error("Failed to create module") @@ -556,7 +556,7 @@ export function HumanLanguagePage() { const next = nextMissingPositive(usedNumbers) const title = `Module-${moduleNo}.${next}` await createSubModuleInModule(moduleId, title, `${level} ${title}`, next) - await loadHierarchy() + await loadHierarchy(false) } catch (error) { console.error("Failed to create sub-module:", error) toast.error("Failed to create sub-module") From 06a0daedfe36b889fb27b093f21ac04715151068 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 14 Apr 2026 06:27:30 -0700 Subject: [PATCH 10/58] refine role management cards with richer metadata Improve role card visual hierarchy and add clearer metadata (role type, id, created date, description preview) so the list feels less sparse and more informative. Made-with: Cursor --- src/pages/role-management/RolesListPage.tsx | 42 ++++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/src/pages/role-management/RolesListPage.tsx b/src/pages/role-management/RolesListPage.tsx index be965c2..2d2107b 100644 --- a/src/pages/role-management/RolesListPage.tsx +++ b/src/pages/role-management/RolesListPage.tsx @@ -302,7 +302,7 @@ export function RolesListPage() { {roles.map((role) => (
- +
-

{role.name}

-

- {role.description} +

+ {role.name} +

+

+ {role.description?.trim() || "No description provided for this role."}

- {role.is_system && ( - - System - - )} + + {role.is_system ? "System" : "Custom"} +
-
- - Created {new Date(role.created_at).toLocaleDateString()} - +
+
+

Role ID

+

#{role.id}

+
+
+

Created

+

+ {new Date(role.created_at).toLocaleDateString()} +

+
+
+ +
+ Open details to view permissions - ) : panelTab === "lessons" && categoryId ? ( - handleCreateLesson(subModule.id, lessonRows.length)} + disabled={creatingKey === `lesson-${subModule.id}`} > - - + )} + New lesson + ) : null}
From 177d10de1500637b67aecfda6e13717519145486 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 14 Apr 2026 06:50:25 -0700 Subject: [PATCH 12/58] replace lesson quick-create with structured lesson form Add a Create Lesson dialog in the human language panel so lesson creation mirrors the practice UX while targeting sub_module_lessons data requirements. Made-with: Cursor --- .../content-management/HumanLanguagePage.tsx | 208 ++++++++++++++++-- 1 file changed, 193 insertions(+), 15 deletions(-) diff --git a/src/pages/content-management/HumanLanguagePage.tsx b/src/pages/content-management/HumanLanguagePage.tsx index 419aee0..2607dbd 100644 --- a/src/pages/content-management/HumanLanguagePage.tsx +++ b/src/pages/content-management/HumanLanguagePage.tsx @@ -93,6 +93,14 @@ type PracticeDialogState = practiceId?: number } +type LessonDialogState = + | { open: false } + | { + open: true + subModuleId: number + defaultIndex: number + } + type QuestionDialogState = | { open: false } | { @@ -346,7 +354,13 @@ export function HumanLanguagePage() { const [subModuleCardSelection, setSubModuleCardSelection] = useState>({}) const [practiceQuestionsState, setPracticeQuestionsState] = useState>({}) const [practiceDialog, setPracticeDialog] = useState({ open: false }) + const [lessonDialog, setLessonDialog] = useState({ open: false }) const [questionDialog, setQuestionDialog] = useState({ open: false }) + const [lessonForm, setLessonForm] = useState({ + title: "", + description: "", + introVideoUrl: "", + }) const [practiceForm, setPracticeForm] = useState({ title: "", description: "", @@ -361,6 +375,7 @@ export function HumanLanguagePage() { const [practiceTargetDelete, setPracticeTargetDelete] = useState<{ id: number; title: string } | null>(null) const [questionTargetDelete, setQuestionTargetDelete] = useState<{ id: number; practiceId: number; text: string } | null>(null) const [savingPractice, setSavingPractice] = useState(false) + const [savingLesson, setSavingLesson] = useState(false) const [savingQuestion, setSavingQuestion] = useState(false) const [deletingPractice, setDeletingPractice] = useState(false) const [deletingQuestion, setDeletingQuestion] = useState(false) @@ -368,11 +383,14 @@ export function HumanLanguagePage() { const [loadingQuestionEditId, setLoadingQuestionEditId] = useState(null) /** Show inline field errors only after an explicit save attempt (avoids noisy empty-state on open). */ const [practiceSubmitAttempted, setPracticeSubmitAttempted] = useState(false) + const [lessonSubmitAttempted, setLessonSubmitAttempted] = useState(false) const [questionSubmitAttempted, setQuestionSubmitAttempted] = useState(false) + const [lessonFormTouched, setLessonFormTouched] = useState(false) const [practiceFormTouched, setPracticeFormTouched] = useState(false) const [questionFormTouched, setQuestionFormTouched] = useState(false) const [loadingPracticeForm, setLoadingPracticeForm] = useState(false) const [uploadingPracticeIntroVideo, setUploadingPracticeIntroVideo] = useState(false) + const [uploadingLessonIntroVideo, setUploadingLessonIntroVideo] = useState(false) const renderMediaPreview = ( urlRaw: string, @@ -566,23 +584,84 @@ export function HumanLanguagePage() { } } - const handleCreateLesson = async (subModuleId: number, currentLessonsCount: number) => { - const key = `lesson-${subModuleId}` - setCreatingKey(key) + const resetLessonForm = () => + setLessonForm({ + title: "", + description: "", + introVideoUrl: "", + }) + + const openCreateLessonDialog = (subModuleId: number, currentLessonsCount: number) => { + setLessonSubmitAttempted(false) + setLessonFormTouched(false) + const next = (currentLessonsCount || 0) + 1 + setLessonForm({ + title: `Lesson ${next}`, + description: "", + introVideoUrl: "", + }) + setLessonDialog({ open: true, subModuleId, defaultIndex: next }) + } + + const lessonFieldErrors = useMemo(() => { + const title = lessonForm.title.trim() + return { + title: title ? undefined : "Title is required.", + } + }, [lessonForm.title]) + + const lessonCanSave = !lessonFieldErrors.title + + const handleSaveLesson = async () => { + if (!lessonDialog.open) return + if (!lessonCanSave) { + setLessonSubmitAttempted(true) + return + } + try { - const next = (currentLessonsCount || 0) + 1 + setSavingLesson(true) await createLesson({ - sub_module_id: subModuleId, - title: `Lesson ${next}`, - description: `Auto-created lesson ${next}`, + sub_module_id: lessonDialog.subModuleId, + title: lessonForm.title.trim(), + description: lessonForm.description.trim() || undefined, + intro_video_url: lessonForm.introVideoUrl.trim() || undefined, }) toast.success("Lesson created") + setLessonDialog({ open: false }) + setLessonSubmitAttempted(false) + setLessonFormTouched(false) + resetLessonForm() await loadHierarchy(false) } catch (error) { console.error("Failed to create lesson:", error) toast.error("Failed to create lesson") } finally { - setCreatingKey(null) + setSavingLesson(false) + } + } + + const handleLessonIntroVideoFileChange = async (event: ChangeEvent) => { + const file = event.target.files?.[0] + event.target.value = "" + if (!file) return + setUploadingLessonIntroVideo(true) + try { + const uploadRes = await uploadVideoFile(file, { + title: lessonForm.title.trim() || file.name.replace(/\.[^.]+$/, "") || "Lesson intro", + description: lessonForm.description.trim() || undefined, + }) + const finalUrl = uploadRes.data?.data?.embed_url?.trim() + ? `${uploadRes.data.data.embed_url}?h=${uploadRes.data.data.url?.split("/").filter(Boolean).at(-1) ?? ""}` + : uploadRes.data?.data?.url?.trim() + if (!finalUrl) throw new Error("Missing uploaded video url") + setLessonForm((prev) => ({ ...prev, introVideoUrl: finalUrl })) + toast.success("Lesson intro video uploaded") + } catch (error) { + console.error("Failed to upload lesson intro video:", error) + toast.error("Failed to upload lesson intro video") + } finally { + setUploadingLessonIntroVideo(false) } } @@ -1643,14 +1722,9 @@ export function HumanLanguagePage() { size="sm" variant="outline" className="h-8 border-grayScale-200 bg-white px-2 text-[11px] hover:border-brand-200 hover:bg-brand-50/40" - onClick={() => handleCreateLesson(subModule.id, lessonRows.length)} - disabled={creatingKey === `lesson-${subModule.id}`} + onClick={() => openCreateLessonDialog(subModule.id, lessonRows.length)} > - {creatingKey === `lesson-${subModule.id}` ? ( - - ) : ( - - )} + New lesson ) : null} @@ -2119,6 +2193,110 @@ export function HumanLanguagePage() { + { + if (!open) { + setLessonDialog({ open: false }) + setLessonSubmitAttempted(false) + setLessonFormTouched(false) + resetLessonForm() + } + }} + > + + + Create Lesson + + Create a lesson as a `sub_module_lessons` entry linked to a QUIZ question set. + {!lessonCanSave ? ( + Required fields must be completed before you can save. + ) : null} + + +
+
+ + { + setLessonFormTouched(true) + setLessonForm((p) => ({ ...p, title: e.target.value })) + }} + className={cn( + "h-10 w-full rounded-md border px-3 text-sm", + (lessonSubmitAttempted || lessonFormTouched) && lessonFieldErrors.title + ? "border-red-300 ring-1 ring-red-200" + : "border-grayScale-200", + )} + placeholder={lessonDialog.open ? `Lesson ${lessonDialog.defaultIndex}` : "Lesson title"} + aria-invalid={Boolean((lessonSubmitAttempted || lessonFormTouched) && lessonFieldErrors.title)} + /> + {(lessonSubmitAttempted || lessonFormTouched) && lessonFieldErrors.title ? ( +

{lessonFieldErrors.title}

+ ) : null} +
+
+ +