diff --git a/docs/course-management-api-integration.md b/docs/course-management-api-integration.md
new file mode 100644
index 0000000..ed44e64
--- /dev/null
+++ b/docs/course-management-api-integration.md
@@ -0,0 +1,479 @@
+# Course Management API Integration Guide
+
+This document describes the Course Management related APIs used by the admin frontend (`Yimaru-Admin`) and how to integrate them safely.
+
+It is based on:
+- `src/api/courses.api.ts`
+- `src/api/files.api.ts`
+- `src/types/course.types.ts`
+- `src/api/http.ts`
+
+---
+
+## 1) Base setup and auth behavior
+
+### Base URL
+- All requests use `VITE_API_BASE_URL` from environment.
+
+### Authentication
+- Access token is sent automatically as `Authorization: Bearer `.
+- On `401`, the frontend attempts token refresh via:
+ - `POST /auth/refresh`
+ - payload: `{ access_token, refresh_token, role, member_id }`
+- If refresh fails, auth data is cleared and user is redirected to `/login`.
+
+### Transport notes
+- Axios automatically handles `multipart/form-data` boundaries for file upload.
+- Any network failure without response also redirects to `/login` (current client behavior).
+
+---
+
+## 2) Core domain model used by frontend
+
+Current hierarchy used by content management:
+- `Category`
+ - `Sub-category`
+ - `Course`
+ - `Level (CEFR)`
+ - `Module`
+ - `Sub-module`
+ - `Videos`
+ - `Lessons` (question sets with `set_type = QUIZ`)
+ - `Practices` (question sets with `set_type = PRACTICE`)
+
+Important migration note:
+- Some APIs/types are marked as legacy (`Program`, old `Level/Module` flows).
+- Current frontend mostly uses unified hierarchy endpoints under `/course-management/...` plus `/question-sets` and `/questions`.
+
+---
+
+## 3) File/media APIs (used by course management)
+
+## 3.1 Upload media
+
+### Endpoint
+- `POST /files/upload`
+
+### Supports
+- `media_type`: `"image" | "audio" | "video"`
+- File upload via multipart (`file`) or URL import via JSON (`source_url`).
+
+### For video uploads
+- Can send optional `title` and `description`.
+
+### Typical response fields used by frontend
+- `data.object_key`
+- `data.url`
+- `data.provider` (`MINIO` or `VIMEO`)
+- `data.vimeo_id`
+- `data.embed_url`
+
+### Frontend wrapper functions
+- `uploadAudioFile(fileOrUrl)`
+- `uploadImageFile(fileOrUrl)`
+- `uploadVideoFile(fileOrUrl, { title?, description? })`
+
+## 3.2 Resolve object key to URL
+
+### Endpoint
+- `GET /files/url?key=`
+
+### Use case
+- Resolve media object key when only key is stored.
+
+---
+
+## 4) Category and course APIs
+
+## 4.1 Get categories (normalized in frontend)
+
+### Endpoint called
+- `GET /course-management/hierarchy`
+
+### Frontend behavior
+- Client transforms flat hierarchy rows into category list.
+- Duplicated category names are merged client-side by "richest" record.
+
+### Wrapper
+- `getCourseCategories()`
+
+## 4.2 Create category or sub-category
+
+### Category
+- `POST /course-management/categories`
+- body: `{ name }`
+
+### Sub-category
+- `POST /course-management/sub-categories`
+- body: `{ category_id, name }`
+
+### Wrapper
+- `createCourseCategory({ name, parent_id? })`
+ - if `parent_id` exists, creates sub-category; else category.
+
+## 4.3 Delete category/sub-category
+- `DELETE /course-management/categories/:categoryId`
+- `DELETE /course-management/sub-categories/:subCategoryId`
+
+Wrappers:
+- `deleteCourseCategory(categoryId)`
+- `deleteCourseSubCategory(subCategoryId)`
+
+## 4.4 Courses by category
+
+### Endpoint called
+- `GET /course-management/hierarchy`
+
+### Frontend behavior
+- Filters and maps rows to courses client-side.
+- If duplicate category names exist, it includes rows matching requested category name.
+
+Wrapper:
+- `getCoursesByCategory(categoryId)`
+
+## 4.5 Course CRUD
+- `POST /course-management/courses`
+- `PUT /course-management/courses/:courseId`
+- `PUT /course-management/courses/:courseId` (status toggle via `is_active`)
+- `DELETE /course-management/courses/:courseId`
+- `POST /course-management/courses/:courseId/thumbnail`
+
+Wrappers:
+- `createCourse(data)`
+- `updateCourse(courseId, data)`
+- `updateCourseStatus(courseId, isActive)`
+- `deleteCourse(courseId)`
+- `updateCourseThumbnail(courseId, thumbnailUrl)`
+
+---
+
+## 5) Course hierarchy (levels/modules/sub-modules)
+
+## 5.1 Get full hierarchy for one course
+
+### Endpoint
+- `GET /course-management/courses/:courseId/hierarchy`
+
+### Wrapper
+- `getSubModulesByCourse(courseId)`
+
+### Frontend behavior
+- Maps hierarchy rows into `sub_courses` shape (compatibility naming).
+- This is the primary source for module/sub-module tree rendering.
+
+## 5.2 Create sub-module flow (composed)
+
+`createSubModule(data)` is a multi-step client workflow:
+1. `POST /course-management/levels`
+2. `POST /course-management/modules`
+3. `POST /course-management/sub-modules`
+
+Use this when creating a new sub-module from minimal info.
+
+## 5.3 Direct level/module/sub-module creation
+- `createModuleInLevel(levelId, title, description, displayOrder?)`
+ - `POST /course-management/modules`
+- `createSubModuleInModule(moduleId, title, description, displayOrder?)`
+ - `POST /course-management/sub-modules`
+
+## 5.4 Update/delete sub-module
+- `PUT /course-management/sub-modules/:subModuleId`
+- `PUT /course-management/sub-modules/:subModuleId` (status payload)
+- `DELETE /course-management/sub-modules/:subModuleId`
+- `POST /course-management/sub-courses/:subModuleId/thumbnail` (compat endpoint)
+
+Wrappers:
+- `updateSubModule(...)`
+- `updateSubModuleStatus(...)`
+- `deleteSubModule(...)`
+- `updateSubModuleThumbnail(...)`
+
+---
+
+## 6) Video APIs (sub-module videos)
+
+## 6.1 List videos for sub-module
+- `GET /course-management/sub-modules/:subModuleId/videos`
+- wrapper: `getVideosBySubModule(subModuleId)`
+
+## 6.2 Create video
+
+Two wrapper variants, same endpoint:
+- `POST /course-management/sub-module-videos`
+
+### Minimal variant
+- `createSubCourseVideo({ sub_module_id|sub_course_id, title, description, video_url })`
+
+### Extended variant
+- `createCourseVideo({ sub_module_id|sub_course_id, title, description, video_url, duration, resolution?, visibility?, display_order?, status? })`
+
+## 6.3 Update/delete video
+- `PUT /course-management/sub-module-videos/:videoId`
+- `DELETE /course-management/sub-module-videos/:videoId`
+
+Wrappers:
+- `updateSubCourseVideo(videoId, data)`
+- `deleteSubCourseVideo(videoId)`
+
+---
+
+## 7) Practices and lessons
+
+## 7.1 Practices by sub-module
+- `GET /question-sets/by-owner?owner_type=SUB_MODULE&owner_id=:subModuleId`
+- wrapper: `getPracticesBySubModule(subModuleId)`
+
+## 7.2 Create practice (composed)
+
+`createPractice(data)` does:
+1. `POST /question-sets`
+ - `set_type: "PRACTICE"`
+ - `owner_type: "SUB_MODULE"`
+ - `owner_id: sub_module_id`
+2. If step 1 succeeds, links to sub-module practice:
+ - `POST /course-management/sub-module-practices`
+ - includes `question_set_id` and intro metadata
+
+## 7.3 Create lesson (composed)
+
+`createLesson(data)` does:
+1. `POST /question-sets`
+ - `set_type: "QUIZ"`
+ - `owner_type: "SUB_MODULE"`
+2. Link question set as lesson:
+ - `POST /course-management/sub-module-lessons`
+
+## 7.4 Practice update/delete/status
+- `PUT /course-management/practices/:practiceId`
+- `PUT /course-management/practices/:practiceId` (status)
+- `DELETE /course-management/practices/:practiceId`
+
+Wrappers:
+- `updatePractice(...)`
+- `updatePracticeStatus(...)`
+- `deletePractice(...)`
+
+---
+
+## 8) Question sets and questions
+
+## 8.1 Question sets
+- `GET /question-sets` with optional query params
+- `GET /question-sets/by-owner`
+- `GET /question-sets/:id`
+- `PUT /question-sets/:id`
+- `DELETE /question-sets/:id`
+- `POST /question-sets`
+
+Wrappers:
+- `getQuestionSets(params?)`
+- `getQuestionSetsByOwner(ownerType, ownerId)`
+- `getQuestionSetById(questionSetId)`
+- `createQuestionSet(data)`
+- `updateQuestionSet(questionSetId, partialData)`
+- `deleteQuestionSet(questionSetId)`
+
+## 8.2 Question list within set
+- `GET /question-sets/:questionSetId/questions`
+- `POST /question-sets/:questionSetId/questions` (add by question id)
+
+Wrappers:
+- `getQuestionSetQuestions(questionSetId)`
+- `addQuestionToSet(questionSetId, { question_id, display_order? })`
+
+## 8.3 Questions CRUD
+- `GET /questions` (filters)
+- `GET /questions/:questionId`
+- `POST /questions`
+- `PUT /questions/:questionId`
+- `DELETE /questions/:questionId`
+
+Wrappers:
+- `getQuestions(params)`
+- `getQuestionById(questionId)`
+- `createQuestion(data)`
+- `updateQuestion(questionId, data)`
+- `deleteQuestion(questionId)`
+
+## 8.4 Practice-question convenience wrappers
+
+`createPracticeQuestion(data)`:
+1. Creates question via `POST /questions`
+2. Adds it to practice set via `POST /question-sets/:practiceId/questions`
+
+`updatePracticeQuestion(questionId, data)`:
+- maps to `PUT /questions/:questionId`
+
+`deletePracticeQuestion(questionId)`:
+- `DELETE /questions/:questionId`
+
+## 8.5 Practice question listing endpoint variants
+- `getPracticeQuestions(practiceId)` -> `GET /question-sets/:practiceId/questions`
+- `getPracticeQuestionsByPractice(practiceId, params)` -> `GET /practices/:practiceId/questions`
+
+Use the second when you need pagination/filtering by question type.
+
+---
+
+## 9) Human language specific APIs
+
+## 9.1 Human language hierarchy
+- `getHumanLanguageHierarchy()`
+- Calls `GET /course-management/hierarchy`
+- If backend already returns nested `sub_categories`, uses it directly.
+- If backend returns flat rows, client builds nested structure and enriches each course by:
+ - requesting `/course-management/courses/:courseId/hierarchy`
+ - requesting `/question-sets/by-owner` per sub-module
+ - deriving lessons from question sets where `set_type = "QUIZ"`
+
+This method is heavier than basic endpoints and can issue many requests.
+
+## 9.2 Human language lessons by course+level
+- `GET /course-management/human-language/courses/:courseId/lessons?cefr_level=...`
+- wrapper: `getHumanLanguageLessonsByCourse(courseId, cefrLevel)`
+
+## 9.3 Create human language lesson structure
+
+`createHumanLanguageLesson(data)` is composed:
+1. `POST /course-management/levels`
+2. `POST /course-management/modules`
+3. `POST /course-management/sub-modules`
+
+---
+
+## 10) Learning path and assessments
+
+- `GET /course-management/courses/:courseId/learning-path`
+ - wrapper: `getLearningPath(courseId)`
+
+- `GET /question-sets/sub-courses/:subModuleId/entry-assessment`
+ - wrapper: `getSubModuleEntryAssessment(subModuleId)`
+
+---
+
+## 11) Unsupported or stubbed features in current frontend API layer
+
+The following wrappers are intentionally stubbed in frontend and return resolved promises (no real backend call):
+- `getSubModulePrerequisites`
+- `addSubModulePrerequisite`
+- `removeSubModulePrerequisite`
+- `reorderCategories`
+- `reorderCourses`
+- `reorderSubModules`
+- `reorderVideos`
+- `reorderPractices`
+
+Implication:
+- UI may appear to support these flows, but persistence is not implemented through backend yet.
+
+---
+
+## 12) Legacy endpoints still exposed (backward compatibility)
+
+These are still present in `courses.api.ts` but marked deprecated in types:
+- Programs APIs
+- Old levels APIs
+- Old modules APIs
+- Practices by level/module APIs
+
+Prefer unified hierarchy/sub-module/question-set APIs for new work.
+
+---
+
+## 13) Integration patterns and recommendations
+
+## 13.1 Safe creation flows
+- For practice/lesson creation, keep composed behavior:
+ - create question set first
+ - then link to sub-module entity
+- Handle partial failure:
+ - if link step fails after question set creation, frontend should show recoverable error and optionally support manual relink.
+
+## 13.2 Request normalization
+- `getQuestionSetsResponse.data` can be either:
+ - raw array
+ - object with `question_sets`
+- Normalize before rendering.
+
+## 13.3 Question type mapping
+- UI uses `"SHORT"`; backend commonly expects `"SHORT_ANSWER"`.
+- Existing wrappers already map `"SHORT"` to `"SHORT_ANSWER"` on create/update practice question.
+
+## 13.4 Media handling
+- Prefer using `/files/upload` wrappers for all media.
+- For Vimeo-backed responses, frontend typically consumes `embed_url` (and may append hash from page URL where applicable).
+
+## 13.5 Retry behavior
+- Some hierarchy fetches use single retry (`withSingleRetry`) for resiliency against transient auth/network race conditions.
+
+---
+
+## 14) Quick endpoint index
+
+### Course management
+- `GET /course-management/hierarchy`
+- `POST /course-management/categories`
+- `POST /course-management/sub-categories`
+- `DELETE /course-management/categories/:id`
+- `DELETE /course-management/sub-categories/:id`
+- `POST /course-management/courses`
+- `PUT /course-management/courses/:id`
+- `DELETE /course-management/courses/:id`
+- `POST /course-management/courses/:id/thumbnail`
+- `GET /course-management/courses/:courseId/hierarchy`
+- `POST /course-management/levels`
+- `POST /course-management/modules`
+- `PUT /course-management/levels/:id`
+- `DELETE /course-management/levels/:id`
+- `PUT /course-management/modules/:id`
+- `DELETE /course-management/modules/:id`
+- `POST /course-management/sub-modules`
+- `PUT /course-management/sub-modules/:id`
+- `DELETE /course-management/sub-modules/:id`
+- `GET /course-management/sub-modules/:subModuleId/videos`
+- `POST /course-management/sub-module-videos`
+- `PUT /course-management/sub-module-videos/:id`
+- `DELETE /course-management/sub-module-videos/:id`
+- `POST /course-management/sub-module-practices`
+- `POST /course-management/sub-module-lessons`
+- `GET /course-management/courses/:courseId/learning-path`
+- `GET /course-management/human-language/courses/:courseId/lessons`
+
+### Question sets and questions
+- `GET /question-sets`
+- `GET /question-sets/by-owner`
+- `GET /question-sets/:id`
+- `POST /question-sets`
+- `PUT /question-sets/:id`
+- `DELETE /question-sets/:id`
+- `GET /question-sets/:id/questions`
+- `POST /question-sets/:id/questions`
+- `GET /practices/:practiceId/questions`
+- `GET /questions`
+- `GET /questions/:id`
+- `POST /questions`
+- `PUT /questions/:id`
+- `DELETE /questions/:id`
+- `POST /questions/audio-answer`
+
+### File/media
+- `POST /files/upload`
+- `GET /files/url`
+- `GET /vimeo/sample`
+- `POST /vimeo/uploads/pull`
+
+---
+
+## 15) Suggested frontend service contract shape
+
+For any new frontend module, follow this contract:
+- **Input DTOs**: UI-friendly types (can include UI aliases like `SHORT`)
+- **Mapper layer**: convert UI DTOs to backend DTOs
+- **Transport layer**: pure API calls
+- **Normalizer layer**: normalize polymorphic responses (`array` vs `object`)
+- **Error policy**:
+ - show user-actionable toast
+ - preserve enough context to retry failed composed steps
+
+This keeps integration robust even with mixed legacy/unified backend surfaces.
+
diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts
index c93de0f..ec88619 100644
--- a/src/api/courses.api.ts
+++ b/src/api/courses.api.ts
@@ -47,8 +47,20 @@ import type {
GetSubCoursePrerequisitesResponse,
AddSubCoursePrerequisiteRequest,
GetLearningPathResponse,
+ GetSubModuleLessonDetailResponse,
GetHumanLanguageLessonsResponse,
- GetHumanLanguageHierarchyResponse,
+ GetSubModuleLessonsResponse,
+ GetHumanLanguageSubCategoriesResponse,
+ GetCategorySubCategoriesResponse,
+ GetSubCategoryCoursesResponse,
+ GetCourseLevelsForCourseResponse,
+ GetCourseLevelsAllResponse,
+ GetCourseLevelByIdResponse,
+ GetHumanLanguageHierarchyFlatResponse,
+ GetCourseHierarchyResponse,
+ GetSubModulesByModuleResponse,
+ CourseHierarchyRow,
+ SubCourse,
CreateHumanLanguageLessonRequest,
GetSubCourseEntryAssessmentResponse,
ReorderItem,
@@ -56,6 +68,8 @@ import type {
GetRatingsParams,
GetVimeoSampleResponse,
CreateCourseVideoRequest,
+ UpdateSubModuleLessonRequest,
+ UpdateSubModuleLessonResponse,
} from "../types/course.types"
type UnifiedHierarchyRow = {
@@ -67,21 +81,22 @@ type UnifiedHierarchyRow = {
course_title?: string | null
}
-type CourseHierarchyRow = {
- course_id: number
- course_title: string
- level_id?: number | null
- cefr_level?: string | null
- module_id?: number | null
- module_title?: string | null
- sub_module_id?: number | null
- sub_module_title?: string | null
+async function withSingleRetry(request: () => Promise, retryDelayMs = 400): Promise {
+ try {
+ return await request()
+ } catch {
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs))
+ return request()
+ }
}
export const getCourseCategories = () =>
- http.get("/course-management/hierarchy").then((res) => {
+ withSingleRetry(() => 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 +104,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: {
@@ -110,20 +165,61 @@ 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}`)
+
+export const getSubCategoriesByCategoryId = (categoryId: number) =>
+ http.get(`/course-management/categories/${categoryId}/sub-categories`)
+
+export const createSubCategory = (payload: {
+ category_id: number
+ name: string
+ description?: string | null
+ display_order?: number
+}) => http.post("/course-management/sub-categories", payload)
+
+export const updateSubCategory = (
+ subCategoryId: number,
+ payload: Partial<{
+ name: string
+ description: string | null
+ is_active: boolean
+ display_order: number
+ }>,
+) => http.patch(`/course-management/sub-categories/${subCategoryId}`, payload)
+
export const getCoursesByCategory = (categoryId: number) =>
- http.get("/course-management/hierarchy").then((res) => {
+ withSingleRetry(() => 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 } },
@@ -147,17 +243,39 @@ export const updateCourseStatus = (courseId: number, isActive: boolean) =>
export const updateCourse = (courseId: number, data: UpdateCourseRequest) =>
http.put(`/course-management/courses/${courseId}`, data)
+export const getCourseHierarchyByCourseId = (courseId: number) =>
+ http.get(`/course-management/courses/${courseId}/hierarchy`)
+
// Sub-Module APIs (Unified Hierarchy)
export const getSubModulesByCourse = (courseId: number) =>
- http.get(`/course-management/courses/${courseId}/hierarchy`).then((res) => {
- const rows: CourseHierarchyRow[] = res.data?.data ?? []
- const subModuleMap = new Map()
+ getCourseHierarchyByCourseId(courseId).then((res) => {
+ const raw = res.data?.data
+ const rows: CourseHierarchyRow[] = Array.isArray(raw) ? raw : []
+ const subModuleMap = new Map<
+ number,
+ {
+ id: number
+ course_id: number
+ level_id?: number
+ module_id?: number
+ title: string
+ description: string
+ level: string
+ cefr_level?: string
+ thumbnail: string
+ display_order: number
+ sub_level?: string
+ is_active: boolean
+ }
+ >()
rows.forEach((r, idx) => {
if (!r.sub_module_id) return
- if (!subModuleMap.has(r.sub_module_id)) {
+ const existing = subModuleMap.get(r.sub_module_id)
+ if (!existing) {
subModuleMap.set(r.sub_module_id, {
id: r.sub_module_id,
course_id: courseId,
+ level_id: r.level_id ?? undefined,
module_id: r.module_id ?? undefined,
title: r.sub_module_title ?? "",
description: "",
@@ -168,7 +286,17 @@ export const getSubModulesByCourse = (courseId: number) =>
sub_level: r.cefr_level ?? undefined,
is_active: true,
})
+ return
}
+ subModuleMap.set(r.sub_module_id, {
+ ...existing,
+ module_id: existing.module_id ?? r.module_id ?? undefined,
+ level_id: existing.level_id ?? r.level_id ?? undefined,
+ title: existing.title || r.sub_module_title || "",
+ level: existing.level || r.cefr_level || "",
+ cefr_level: existing.cefr_level ?? r.cefr_level ?? undefined,
+ sub_level: existing.sub_level ?? r.cefr_level ?? undefined,
+ })
})
const sub_courses = Array.from(subModuleMap.values())
return {
@@ -225,6 +353,33 @@ export const deleteSubModule = (subModuleId: number) =>
export const getVideosBySubModule = (subModuleId: number) =>
http.get(`/course-management/sub-modules/${subModuleId}/videos`)
+export const getLessonsBySubModule = (subModuleId: number, options?: { includeInactive?: boolean }) =>
+ http.get(`/course-management/sub-modules/${subModuleId}/lessons`, {
+ params: { include_inactive: options?.includeInactive ?? true },
+ })
+
+export const getSubModuleLessonById = (
+ lessonId: number,
+ options?: {
+ /**
+ * Cache-bust the request to avoid serving stale lesson data after edits.
+ * This is intentionally implemented via query string to work with default axios config.
+ */
+ cacheBust?: boolean
+ },
+) =>
+ http.get(`/course-management/sub-module-lessons/${lessonId}`, {
+ params: options?.cacheBust ? { _t: Date.now() } : undefined,
+ })
+
+export const updateSubModuleLesson = (lessonId: number, data: UpdateSubModuleLessonRequest) =>
+ http.put(`/course-management/sub-module-lessons/${lessonId}`, data)
+
+export const softDeleteSubModuleLesson = (lessonId: number) =>
+ http.put(`/course-management/sub-module-lessons/${lessonId}`, {
+ is_active: false,
+ })
+
export const createSubCourseVideo = (data: CreateSubCourseVideoRequest) =>
http.post("/course-management/sub-module-videos", {
sub_module_id: data.sub_module_id ?? data.sub_course_id,
@@ -285,6 +440,43 @@ export const createPractice = (data: CreatePracticeRequest) =>
.then(() => res)
})
+export const createLesson = (data: {
+ sub_module_id: number
+ title: string
+ description?: string
+ intro_video_url?: string
+ persona?: string
+ status?: "DRAFT" | "PUBLISHED"
+ passing_score?: number
+ time_limit_minutes?: number
+ shuffle_questions?: boolean
+}) =>
+ http
+ .post("/question-sets", {
+ title: data.title,
+ set_type: "QUIZ",
+ owner_type: "SUB_MODULE",
+ owner_id: data.sub_module_id,
+ ...(data.description?.trim() ? { description: data.description.trim() } : {}),
+ ...(data.intro_video_url?.trim() ? { intro_video_url: data.intro_video_url.trim() } : {}),
+ ...(data.persona?.trim() ? { persona: data.persona.trim() } : {}),
+ ...(data.status ? { status: data.status } : {}),
+ ...(Number.isFinite(data.passing_score) ? { passing_score: data.passing_score } : {}),
+ ...(Number.isFinite(data.time_limit_minutes) ? { time_limit_minutes: data.time_limit_minutes } : {}),
+ ...(typeof data.shuffle_questions === "boolean" ? { shuffle_questions: data.shuffle_questions } : {}),
+ })
+ .then((res) => {
+ const questionSetID = res.data?.data?.id
+ if (!questionSetID) return res
+ return http
+ .post("/course-management/sub-module-lessons", {
+ sub_module_id: data.sub_module_id,
+ question_set_id: questionSetID,
+ intro_video_url: data.intro_video_url,
+ })
+ .then(() => res)
+ })
+
export const updatePractice = (practiceId: number, data: UpdatePracticeRequest) =>
http.put(`/course-management/practices/${practiceId}`, data)
@@ -506,186 +698,92 @@ export const getHumanLanguageLessonsByCourse = (courseId: number, cefr_level: st
params: { cefr_level },
})
-export const getHumanLanguageHierarchy = () =>
- 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
- }
+export const getHumanLanguageSubCategories = () =>
+ http.get("/course-management/human-language/sub-categories")
- 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
- }
- >
- }
- >
- }
- >()
+export const getCoursesBySubCategoryId = (subCategoryId: number) =>
+ http.get(`/course-management/sub-categories/${subCategoryId}/courses`)
- rows.forEach((row) => {
- const categoryId = Number(row.category_id)
- if (!Number.isFinite(categoryId)) return
+export const getSubModulesByModuleId = (moduleId: number) =>
+ http.get(`/course-management/modules/${moduleId}/sub-modules`)
- 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 ?? "",
- })
- }
+/**
+ * Finds a sub-module under a course by walking levels → modules → sub-modules APIs.
+ * Use when the legacy hierarchy flatten (`getSubModulesByCourse`) does not include the row.
+ */
+export async function resolveSubModuleForCourse(
+ courseId: number,
+ subModuleId: number,
+): Promise {
+ try {
+ const levelsRes = await getCourseLevelsForCourse(courseId)
+ const levels = Array.isArray(levelsRes.data?.data?.levels) ? levelsRes.data.data.levels : []
+ const sortedLevels = [...levels].sort((a, b) => {
+ const o = (a.display_order ?? 0) - (b.display_order ?? 0)
+ if (o !== 0) return o
+ return String(a.cefr_level ?? "").localeCompare(String(b.cefr_level ?? ""))
})
- 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()),
- })),
- })),
- }
+ const modulesNested = await Promise.all(
+ sortedLevels.map(async (level) => {
+ const modsRes = await getModulesByLevel(level.id)
+ const rawMods = modsRes.data?.data?.modules
+ const modules = Array.isArray(rawMods) ? rawMods : []
+ const sortedMods = [...modules].sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
+ return sortedMods.map((module) => ({ level, module }))
}),
- }))
+ )
+ const modulePairs = modulesNested.flat()
- return {
- ...res,
- data: {
- ...res.data,
- data: {
- category_id: selectedCategory.category_id,
- category_name: selectedCategory.category_name,
- sub_categories: subCategories,
- },
- },
- } as unknown as { data: GetHumanLanguageHierarchyResponse }
- })
+ const bundles = await Promise.all(
+ modulePairs.map(async ({ level, module }) => {
+ const subsRes = await getSubModulesByModuleId(module.id)
+ const rawSubs = subsRes.data?.data?.sub_modules
+ const subs = Array.isArray(rawSubs) ? rawSubs : []
+ const sortedSubs = [...subs].sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
+ return { level, module, subs: sortedSubs }
+ }),
+ )
+
+ for (const { level, module, subs } of bundles) {
+ const found = subs.find((s) => s.id === subModuleId)
+ if (found) {
+ return {
+ id: found.id,
+ course_id: courseId,
+ level_id: level.id,
+ module_id: module.id,
+ title: found.title,
+ description: found.description ?? "",
+ level: level.cefr_level,
+ cefr_level: level.cefr_level,
+ thumbnail: found.thumbnail ?? "",
+ display_order: found.display_order,
+ sub_level: level.cefr_level,
+ is_active: found.is_active,
+ }
+ }
+ }
+ } catch (e) {
+ console.error("resolveSubModuleForCourse failed:", e)
+ }
+ return null
+}
+
+export const getCourseLevelsForCourse = (courseId: number) =>
+ http.get(`/course-management/courses/${courseId}/levels`)
+
+export const getAllCourseLevels = () => http.get("/course-management/levels")
+
+export const getCourseLevelById = (levelId: number) =>
+ http.get(`/course-management/levels/${levelId}`)
+
+export const getHumanLanguageHierarchy = (options?: { cacheBust?: boolean }) =>
+ withSingleRetry(() =>
+ http.get("/course-management/human-language/hierarchy", {
+ params: options?.cacheBust ? { _t: Date.now() } : undefined,
+ }),
+ )
export const createHumanLanguageLesson = (data: CreateHumanLanguageLessonRequest) =>
http
@@ -714,6 +812,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/api/http.ts b/src/api/http.ts
index 5ba1228..893c181 100644
--- a/src/api/http.ts
+++ b/src/api/http.ts
@@ -12,6 +12,7 @@ let failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: Error) => void;
}> = [];
+const TOKEN_REFRESH_BUFFER_SECONDS = 120;
const processQueue = (error: Error | null, token: string | null = null) => {
failedQueue.forEach((prom) => {
@@ -32,23 +33,47 @@ const clearAuthAndRedirect = () => {
window.location.href = "/login";
};
-const refreshAccessToken = async (): Promise => {
- const accessToken = localStorage.getItem("access_token");
- const refreshToken = localStorage.getItem("refresh_token");
- const role = localStorage.getItem("role");
- const memberId = localStorage.getItem("member_id");
+const decodeJwtPayload = (token: string): Record | null => {
+ try {
+ const payloadPart = token.split(".")[1];
+ if (!payloadPart) return null;
+ const normalized = payloadPart.replace(/-/g, "+").replace(/_/g, "/");
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
+ const json = atob(padded);
+ return JSON.parse(json) as Record;
+ } catch {
+ return null;
+ }
+};
- if (!refreshToken || !memberId) {
+const isAccessTokenExpiringSoon = (token: string) => {
+ const payload = decodeJwtPayload(token);
+ const exp = Number(payload?.exp);
+ if (!Number.isFinite(exp)) return true;
+ const nowSeconds = Math.floor(Date.now() / 1000);
+ return exp - nowSeconds <= TOKEN_REFRESH_BUFFER_SECONDS;
+};
+
+const isAuthEndpointRequest = (url?: string) => {
+ if (!url) return false;
+ return (
+ url.includes("/team/login") ||
+ url.includes("/team/google-login") ||
+ url.includes("/team/refresh")
+ );
+};
+
+const refreshAccessToken = async (): Promise => {
+ const refreshToken = localStorage.getItem("refresh_token");
+
+ if (!refreshToken) {
throw new Error("No refresh token available");
}
const response = await axios.post(
- `${import.meta.env.VITE_API_BASE_URL}/auth/refresh`,
+ `${import.meta.env.VITE_API_BASE_URL}/team/refresh`,
{
- access_token: accessToken,
refresh_token: refreshToken,
- role: role || "admin",
- member_id: Number(memberId),
}
);
@@ -65,9 +90,43 @@ const refreshAccessToken = async (): Promise => {
return newAccessToken;
};
+const getValidAccessToken = async (forceRefresh = false): Promise => {
+ const currentToken = localStorage.getItem("access_token");
+ if (!forceRefresh && currentToken && !isAccessTokenExpiringSoon(currentToken)) {
+ return currentToken;
+ }
+
+ if (isRefreshing) {
+ return new Promise((resolve, reject) => {
+ failedQueue.push({ resolve, reject });
+ });
+ }
+
+ isRefreshing = true;
+ try {
+ const newToken = await refreshAccessToken();
+ processQueue(null, newToken);
+ return newToken;
+ } catch (refreshError) {
+ processQueue(refreshError as Error, null);
+ clearAuthAndRedirect();
+ throw refreshError;
+ } finally {
+ isRefreshing = false;
+ }
+};
+
// Attach access token to every request
-http.interceptors.request.use((config) => {
- const token = localStorage.getItem("access_token");
+http.interceptors.request.use(async (config) => {
+ if (isAuthEndpointRequest(config.url)) {
+ return config;
+ }
+
+ let token = localStorage.getItem("access_token");
+ if (token && isAccessTokenExpiringSoon(token)) {
+ token = await getValidAccessToken();
+ }
+
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
@@ -80,32 +139,19 @@ http.interceptors.response.use(
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
- if (error.response?.status === 401 && !originalRequest._retry) {
- if (isRefreshing) {
- return new Promise((resolve, reject) => {
- failedQueue.push({ resolve, reject });
- })
- .then((token) => {
- originalRequest.headers.Authorization = `Bearer ${token}`;
- return http(originalRequest);
- })
- .catch((err) => Promise.reject(err));
- }
-
+ if (
+ error.response?.status === 401 &&
+ !originalRequest._retry &&
+ !isAuthEndpointRequest(originalRequest.url)
+ ) {
originalRequest._retry = true;
- isRefreshing = true;
try {
- const newToken = await refreshAccessToken();
- processQueue(null, newToken);
+ const newToken = await getValidAccessToken(true);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return http(originalRequest);
} catch (refreshError) {
- processQueue(refreshError as Error, null);
- clearAuthAndRedirect();
return Promise.reject(refreshError);
- } finally {
- isRefreshing = false;
}
}
diff --git a/src/api/rbac.api.ts b/src/api/rbac.api.ts
index 160fc18..f14fea8 100644
--- a/src/api/rbac.api.ts
+++ b/src/api/rbac.api.ts
@@ -5,6 +5,7 @@ import type {
GetRolesParams,
CreateRoleRequest,
CreateRoleResponse,
+ DeleteRoleResponse,
SetRolePermissionsRequest,
GetPermissionsResponse,
} from "../types/rbac.types"
@@ -26,3 +27,6 @@ export const setRolePermissions = (roleId: number, data: SetRolePermissionsReque
export const getAllPermissions = () =>
http.get("/rbac/permissions")
+
+export const deleteRole = (roleId: number) =>
+ http.delete(`/rbac/roles/${roleId}`)
diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx
index 5c75232..d14f85d 100644
--- a/src/app/AppRoutes.tsx
+++ b/src/app/AppRoutes.tsx
@@ -10,8 +10,8 @@ import { ContentOverviewPage } from "../pages/content-management/ContentOverview
import { CoursesPage } from "../pages/content-management/CoursesPage"
import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuestionsPage"
import { AddNewPracticePage } from "../pages/content-management/AddNewPracticePage"
+import { AddNewLessonPage } from "../pages/content-management/AddNewLessonPage"
import { SubModulesPage } from "../pages/content-management/SubCoursesPage"
-import { SubModuleContentPage } from "../pages/content-management/SubCourseContentPage"
import { SpeakingPage } from "../pages/content-management/SpeakingPage"
import { AddVideoPage } from "../pages/content-management/AddVideoPage"
import { AddPracticePage } from "../pages/content-management/AddPracticePage"
@@ -31,8 +31,9 @@ import { PracticeDetailsPage } from "../pages/content-management/PracticeDetails
import { PracticeMembersPage } from "../pages/content-management/PracticeMembersPage"
import { QuestionsPage } from "../pages/content-management/QuestionsPage"
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage"
-import { HumanLanguagePage } from "../pages/content-management/HumanLanguagePage"
+import { HumanLanguageHierarchyPage } from "../pages/content-management/HumanLanguageHierarchyPage"
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage"
+import { SubCategoryCoursesPage } from "../pages/content-management/SubCategoryCoursesPage"
import { UserLogPage } from "../pages/user-log/UserLogPage"
import { IssuesPage } from "../pages/issues/IssuesPage"
import { ProfilePage } from "../pages/ProfilePage"
@@ -78,30 +79,44 @@ export function AppRoutes() {
} />
} />
} />
- } />
+ } />
}
/>
+ }
+ />
}
/>
+ }
+ />
}
/>
} />
+ }
+ />
} />
{/* Course → Sub-module → Lesson/Practice */}
} />
- } />
+ } />
} />
+ } />
} />
{/* Legacy aliases */}
} />
- } />
+ } />
} />
+ } />
} />
} />
} />
diff --git a/src/components/topbar/Topbar.tsx b/src/components/topbar/Topbar.tsx
index b42a173..8a3fb58 100644
--- a/src/components/topbar/Topbar.tsx
+++ b/src/components/topbar/Topbar.tsx
@@ -45,7 +45,7 @@ export function Topbar({ onSidebarToggle }: TopbarProps) {
}
return (
-
+
{/* Sidebar toggle */}
(null)
+ const previousRouteKeyRef = useRef("")
+ const location = useLocation()
+ const scrollStoragePrefix = "app:scroll:"
+ const routeKey = useMemo(() => `${location.pathname}${location.search}`, [location.pathname, location.search])
const token = localStorage.getItem("access_token")
- if (!token) {
- return
- }
const handleSidebarToggle = useCallback(() => {
setSidebarOpen((prev) => !prev)
@@ -20,6 +22,43 @@ export function AppLayout() {
setSidebarOpen(false)
}, [])
+ useEffect(() => {
+ const container = mainRef.current
+ if (!container) return
+
+ const saveScroll = (key: string) => {
+ sessionStorage.setItem(`${scrollStoragePrefix}${key}`, String(container.scrollTop || 0))
+ }
+
+ const previousKey = previousRouteKeyRef.current
+ if (previousKey && previousKey !== routeKey) {
+ saveScroll(previousKey)
+ }
+ previousRouteKeyRef.current = routeKey
+
+ const restoreRaw = sessionStorage.getItem(`${scrollStoragePrefix}${routeKey}`)
+ const restoreTop = restoreRaw ? Number(restoreRaw) : 0
+ const top = Number.isFinite(restoreTop) && restoreTop > 0 ? restoreTop : 0
+ requestAnimationFrame(() => {
+ container.scrollTo({ top, behavior: "auto" })
+ })
+
+ const onScroll = () => saveScroll(routeKey)
+ const onBeforeUnload = () => saveScroll(routeKey)
+ container.addEventListener("scroll", onScroll, { passive: true })
+ window.addEventListener("beforeunload", onBeforeUnload)
+
+ return () => {
+ saveScroll(routeKey)
+ container.removeEventListener("scroll", onScroll)
+ window.removeEventListener("beforeunload", onBeforeUnload)
+ }
+ }, [routeKey])
+
+ if (!token) {
+ return
+ }
+
return (
-
+
diff --git a/src/pages/content-management/AddNewLessonPage.tsx b/src/pages/content-management/AddNewLessonPage.tsx
new file mode 100644
index 0000000..1f41850
--- /dev/null
+++ b/src/pages/content-management/AddNewLessonPage.tsx
@@ -0,0 +1,650 @@
+import { useMemo, useState, type ChangeEvent } from "react"
+import { ArrowLeft, ArrowRight, Check, GripVertical, Plus, Rocket, Trash2, Upload } from "lucide-react"
+import { Link, useLocation, useNavigate, useParams } from "react-router-dom"
+import { toast } from "sonner"
+import { addQuestionToSet, createLesson, createQuestion } from "../../api/courses.api"
+import { uploadVideoFile } from "../../api/files.api"
+import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields"
+import { Button } from "../../components/ui/button"
+import { Card } from "../../components/ui/card"
+import { Input } from "../../components/ui/input"
+import { SpinnerIcon } from "../../components/ui/spinner-icon"
+import type { QuestionOption } from "../../types/course.types"
+
+type Step = 1 | 2 | 3 | 4
+type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
+type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
+type ResultStatus = "success" | "error"
+
+interface MCQOption {
+ text: string
+ isCorrect: boolean
+}
+
+interface Question {
+ id: string
+ questionText: string
+ questionType: QuestionType
+ difficultyLevel: DifficultyLevel
+ points: number
+ tips: string
+ explanation: string
+ options: MCQOption[]
+ voicePrompt: string
+ sampleAnswerVoicePrompt: string
+ audioCorrectAnswerText: string
+ shortAnswers: string[]
+ imageUrl: string
+}
+
+const STEPS = [
+ { number: 1, label: "Context" },
+ { number: 2, label: "Questions" },
+ { number: 3, label: "Review" },
+]
+
+function createEmptyQuestion(id: string): Question {
+ return {
+ id,
+ questionText: "",
+ questionType: "MCQ",
+ difficultyLevel: "EASY",
+ points: 1,
+ tips: "",
+ explanation: "",
+ options: [
+ { text: "", isCorrect: true },
+ { text: "", isCorrect: false },
+ { text: "", isCorrect: false },
+ { text: "", isCorrect: false },
+ ],
+ voicePrompt: "",
+ sampleAnswerVoicePrompt: "",
+ audioCorrectAnswerText: "",
+ shortAnswers: [],
+ imageUrl: "",
+ }
+}
+
+function introVideoUrlFromUploadResponse(data: { url?: string; embed_url?: string } | undefined): string | null {
+ if (!data) return null
+ const pageUrl = data.url?.trim()
+ const embedUrl = data.embed_url?.trim()
+ if (embedUrl) {
+ const hashFromUrl = pageUrl ? pageUrl.split("/").filter(Boolean).at(-1) : undefined
+ return hashFromUrl ? `${embedUrl}?h=${hashFromUrl}` : embedUrl
+ }
+ return pageUrl || null
+}
+
+function toVimeoEmbedUrl(rawUrl: string): string | null {
+ try {
+ const parsed = new URL(rawUrl.trim())
+ const host = parsed.hostname.toLowerCase()
+ if (!host.includes("vimeo.com")) return null
+ if (host.includes("player.vimeo.com") && parsed.pathname.includes("/video/")) return parsed.toString()
+ const segments = parsed.pathname.split("/").filter(Boolean)
+ const videoId = segments.find((segment) => /^\d+$/.test(segment))
+ if (!videoId) return null
+ const hash = parsed.searchParams.get("h")
+ return hash
+ ? `https://player.vimeo.com/video/${videoId}?h=${encodeURIComponent(hash)}`
+ : `https://player.vimeo.com/video/${videoId}`
+ } catch {
+ return null
+ }
+}
+
+function isDirectVideoFile(url: string): boolean {
+ const clean = url.split("?")[0].toLowerCase()
+ return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean)
+}
+
+function questionTypeLabel(type: QuestionType): string {
+ if (type === "TRUE_FALSE") return "True/False"
+ if (type === "SHORT") return "Short Answer"
+ if (type === "AUDIO") return "Audio"
+ return "Multiple Choice"
+}
+
+export function AddNewLessonPage() {
+ const { categoryId, courseId, subModuleId } = useParams()
+ const navigate = useNavigate()
+ const location = useLocation()
+
+ const backTo = useMemo(() => {
+ if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/")) {
+ return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}`
+ }
+ return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`
+ }, [categoryId, courseId, subModuleId, location.pathname])
+
+ const [currentStep, setCurrentStep] = useState(1)
+ const [saving, setSaving] = useState(false)
+ const [resultStatus, setResultStatus] = useState(null)
+ const [resultMessage, setResultMessage] = useState("")
+ const [lastSavedStatus, setLastSavedStatus] = useState<"DRAFT" | "PUBLISHED" | null>(null)
+
+ const [lessonTitle, setLessonTitle] = useState("")
+ const [lessonDescription, setLessonDescription] = useState("")
+ const [introVideoUrl, setIntroVideoUrl] = useState("")
+ const [uploadingIntroVideo, setUploadingIntroVideo] = useState(false)
+ const [questions, setQuestions] = useState([createEmptyQuestion("1")])
+
+ const handleNext = () => setCurrentStep((s) => (s < 3 ? ((s + 1) as Step) : s))
+ const handleBack = () => setCurrentStep((s) => (s > 1 ? ((s - 1) as Step) : s))
+
+ const handleIntroVideoFileChange = async (event: ChangeEvent) => {
+ const file = event.target.files?.[0]
+ event.target.value = ""
+ if (!file) return
+ setUploadingIntroVideo(true)
+ try {
+ const uploadRes = await uploadVideoFile(file, {
+ title: lessonTitle.trim() || file.name.replace(/\.[^.]+$/, "") || "Lesson intro",
+ description: lessonDescription.trim() || undefined,
+ })
+ const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data)
+ if (!finalUrl) throw new Error("Missing uploaded video url")
+ setIntroVideoUrl(finalUrl)
+ toast.success("Intro video uploaded")
+ } catch (error) {
+ console.error("Failed to upload lesson intro video:", error)
+ toast.error("Failed to upload intro video")
+ } finally {
+ setUploadingIntroVideo(false)
+ }
+ }
+
+ const handleIntroVideoUrlBlur = async () => {
+ const source = introVideoUrl.trim()
+ if (!source || !/^https?:\/\//i.test(source)) return
+ const vimeoEmbed = toVimeoEmbedUrl(source)
+ if (vimeoEmbed) {
+ setIntroVideoUrl(vimeoEmbed)
+ return
+ }
+ if (isDirectVideoFile(source)) {
+ setIntroVideoUrl(source)
+ return
+ }
+
+ // For non-direct URLs, automatically try server-side import via /files/upload.
+ setUploadingIntroVideo(true)
+ try {
+ const uploadRes = await uploadVideoFile(source, {
+ title: lessonTitle.trim() || "Lesson intro",
+ description: lessonDescription.trim() || undefined,
+ })
+ const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data)
+ if (!finalUrl) throw new Error("Missing uploaded video url")
+ setIntroVideoUrl(finalUrl)
+ toast.success("Intro video URL imported")
+ } catch (error) {
+ console.error("Failed to import intro video URL:", error)
+ toast.error("Failed to import intro video URL")
+ } finally {
+ setUploadingIntroVideo(false)
+ }
+ }
+
+ const introVideoPreview = useMemo(() => {
+ const raw = introVideoUrl.trim()
+ if (!raw) return null
+ const vimeoEmbedUrl = toVimeoEmbedUrl(raw)
+ if (vimeoEmbedUrl) return { kind: "vimeo" as const, url: vimeoEmbedUrl }
+ if (isDirectVideoFile(raw)) return { kind: "video" as const, url: raw }
+ return null
+ }, [introVideoUrl])
+
+ const reviewQuestions = useMemo(() => questions, [questions])
+
+ const addQuestion = () => setQuestions((prev) => [...prev, createEmptyQuestion(String(Date.now()))])
+ const removeQuestion = (id: string) => setQuestions((prev) => (prev.length > 1 ? prev.filter((q) => q.id !== id) : prev))
+ const updateQuestion = (id: string, updates: Partial) =>
+ setQuestions((prev) => prev.map((q) => (q.id === id ? { ...q, ...updates } : q)))
+
+ const saveLesson = async (status: "DRAFT" | "PUBLISHED") => {
+ if (!subModuleId) {
+ toast.error("Missing sub-module id")
+ return
+ }
+ setSaving(true)
+ try {
+ const lessonRes = await createLesson({
+ sub_module_id: Number(subModuleId),
+ title: lessonTitle.trim() || "Untitled Lesson",
+ description: lessonDescription.trim() || undefined,
+ intro_video_url: introVideoUrl.trim() || undefined,
+ status,
+ })
+
+ const questionSetId = lessonRes.data?.data?.id
+ if (questionSetId) {
+ for (let i = 0; i < questions.length; i++) {
+ const q = questions[i]
+ if (!q.questionText.trim()) continue
+ const options: QuestionOption[] =
+ q.questionType === "MCQ"
+ ? q.options.map((opt, idx) => ({
+ option_order: idx + 1,
+ option_text: opt.text,
+ is_correct: opt.isCorrect,
+ }))
+ : []
+
+ const qRes = await createQuestion({
+ question_text: q.questionText,
+ question_type: q.questionType,
+ difficulty_level: q.difficultyLevel,
+ points: q.points,
+ tips: q.tips || undefined,
+ explanation: q.explanation || undefined,
+ status: "PUBLISHED",
+ options: options.length > 0 ? options : undefined,
+ voice_prompt: q.voicePrompt || undefined,
+ sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
+ audio_correct_answer_text: q.audioCorrectAnswerText || undefined,
+ image_url: q.imageUrl.trim() || undefined,
+ short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
+ })
+ const questionId = qRes.data?.data?.id
+ if (questionId) {
+ await addQuestionToSet(questionSetId, { question_id: questionId, display_order: i + 1 })
+ }
+ }
+ }
+
+ setResultStatus("success")
+ setResultMessage(status === "PUBLISHED" ? "Lesson published successfully." : "Lesson saved as draft.")
+ setLastSavedStatus(status)
+ setCurrentStep(4)
+ } catch (error) {
+ console.error("Failed to save lesson:", error)
+ setResultStatus("error")
+ setResultMessage(error instanceof Error ? error.message : "Failed to save lesson")
+ setLastSavedStatus(null)
+ setCurrentStep(4)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
+ {currentStep !== 4 ? (
+ <>
+
+
+ Back to Sub-course
+
+
+
Add New Lesson
+
+ Create a lesson backed by `question_sets` and attach it through `sub_module_lessons`.
+
+
+
+ {STEPS.map((step, index) => (
+
+
+
step.number
+ ? "bg-brand-500 text-white"
+ : "border-2 border-grayScale-300 bg-white text-grayScale-400"
+ }`}
+ >
+ {currentStep > step.number ? : step.number}
+
+
{step.label}
+
+ {index < STEPS.length - 1 ? (
+
step.number ? "bg-brand-500" : "bg-grayScale-200"}`} />
+ ) : null}
+
+ ))}
+
+ >
+ ) : null}
+
+ {currentStep === 1 ? (
+
+
+
Step 1: Context
+
+ Define lesson metadata that will be stored in the linked question set.
+
+
+
+
+
+
+ Lesson title
+ setLessonTitle(e.target.value)}
+ placeholder="Enter lesson title"
+ className="h-11"
+ />
+
+
+ Description
+
+
+
Intro video URL (optional)
+
setIntroVideoUrl(e.target.value)}
+ onBlur={() => void handleIntroVideoUrlBlur()}
+ placeholder="https://..."
+ type="url"
+ inputMode="url"
+ autoComplete="off"
+ className="font-mono text-[13px]"
+ />
+
+
+ {uploadingIntroVideo ? : }
+ {uploadingIntroVideo ? "Uploading..." : "Upload video from computer"}
+
+
+ {introVideoUrl.trim() ? (
+ setIntroVideoUrl("")}>
+ Clear URL
+
+ ) : null}
+
+ {introVideoPreview ? (
+
+
Preview
+ {introVideoPreview.kind === "vimeo" ? (
+
+
+
+ ) : (
+
+ )}
+
+ ) : null}
+
+
+
+
+
Lesson schema mapping
+
+
+ question_sets.title ← Lesson title
+
+
+ question_sets.description ← Description
+
+
+ question_sets.set_type = QUIZ
+
+
+ sub_module_lessons.intro_video_url ← Intro URL
+
+
+
+
+
+
+
+
navigate(backTo)} className="sm:w-auto">
+ Cancel
+
+
+ Next: Questions
+
+
+
+
+ ) : null}
+
+ {currentStep === 2 ? (
+
+ {questions.map((question, index) => (
+
+
+
+
+ Question {index + 1}
+
+
removeQuestion(question.id)} className="text-grayScale-400 hover:text-red-500">
+
+
+
+
+ updateQuestion(question.id, {
+ questionText: next.questionText,
+ questionType: next.questionType as QuestionType,
+ difficultyLevel: next.difficultyLevel as DifficultyLevel,
+ points: next.points,
+ tips: next.tips,
+ explanation: next.explanation,
+ options: next.options,
+ voicePrompt: next.voicePrompt,
+ sampleAnswerVoicePrompt: next.sampleAnswerVoicePrompt,
+ audioCorrectAnswerText: next.audioCorrectAnswerText,
+ shortAnswers: next.shortAnswer.trim() ? [next.shortAnswer.trim()] : [],
+ imageUrl: next.imageUrl,
+ })
+ }
+ mediaBusy={saving}
+ />
+
+ ))}
+
+
+ Add another question
+
+
+
+ Back
+
+
+ Next: Review
+
+
+
+
+ ) : null}
+
+ {currentStep === 3 ? (
+
+
+
Step 3: Review & publish
+
Confirm lesson details and questions before saving or publishing.
+
+
+
+
+
+
Basic Information
+ setCurrentStep(1)}
+ >
+ Edit
+
+
+
+
+ Title
+ {lessonTitle || "Untitled Lesson"}
+
+
+ Description
+
+ {lessonDescription || "—"}
+
+
+
+ Intro video URL
+ {introVideoUrl || "—"}
+
+
+ Sub-module
+ {subModuleId ?? "—"}
+
+
+
+
+
+
+
+ Questions
+
+ {reviewQuestions.length}
+
+
+ setCurrentStep(2)}
+ >
+ Edit
+
+
+
+ {reviewQuestions.length === 0 ? (
+
+ No question content added yet.
+
+ ) : (
+ reviewQuestions.map((question, idx) => (
+
+
+
+ {idx + 1}
+
+
+ {questionTypeLabel(question.questionType)}
+
+
+ {question.difficultyLevel}
+
+ {question.points} pt
+
+
+ {question.questionText.trim() || `Question ${idx + 1}`}
+
+ {question.questionType === "MCQ" ? (
+
+ {question.options.map((option, optionIdx) => (
+
+ {option.text || `Option ${optionIdx + 1}`}
+
+ ))}
+
+ ) : null}
+
+ ))
+ )}
+
+
+
+
+
+
+ Back
+
+
+ void saveLesson("DRAFT")} disabled={saving}>
+ {saving ? "Saving..." : "Save as Draft"}
+
+ void saveLesson("PUBLISHED")} disabled={saving}>
+
+ {saving ? "Publishing..." : "Publish Now"}
+
+
+
+
+ ) : null}
+
+ {currentStep === 4 && resultStatus ? (
+
+
+
+
+
+ {resultStatus === "success" ? "Lesson Published Successfully!" : "Lesson save failed"}
+
+
{resultStatus === "success" ? "Your lesson is now active." : resultMessage}
+
+
+ navigate(lastSavedStatus === "PUBLISHED" ? "/content/human-language" : backTo)
+ }
+ >
+ Go back to Course
+
+ {resultStatus === "success" ? (
+ navigate(0)}>
+ Add Another Lesson
+
+ ) : (
+ setCurrentStep(3)}>
+ Back to Review
+
+ )}
+
+
+ ) : null}
+
+
+ )
+}
diff --git a/src/pages/content-management/AddNewPracticePage.tsx b/src/pages/content-management/AddNewPracticePage.tsx
index b6f01fa..1c8a7f2 100644
--- a/src/pages/content-management/AddNewPracticePage.tsx
+++ b/src/pages/content-management/AddNewPracticePage.tsx
@@ -1,6 +1,6 @@
import { useMemo, useRef, useState, type ChangeEvent } from "react"
import { Link, useLocation, useParams, useNavigate } from "react-router-dom"
-import { ArrowLeft, ArrowRight, ChevronDown, Grid3X3, Check, Plus, Trash2, GripVertical, Edit, Rocket, Loader2, Upload } from "lucide-react"
+import { ArrowLeft, ArrowRight, ChevronDown, Grid3X3, Check, Plus, Trash2, GripVertical, Edit, Rocket, Upload } from "lucide-react"
import { toast } from "sonner"
import { Card } from "../../components/ui/card"
import { Button } from "../../components/ui/button"
@@ -9,6 +9,7 @@ import { PracticeQuestionEditorFields } from "../../components/content-managemen
import { createQuestionSet, createQuestion, addQuestionToSet } from "../../api/courses.api"
import { uploadVideoFile } from "../../api/files.api"
import { Select } from "../../components/ui/select"
+import { SpinnerIcon } from "../../components/ui/spinner-icon"
import type { QuestionOption } from "../../types/course.types"
type Step = 1 | 2 | 3 | 4 | 5
@@ -526,7 +527,7 @@ export function AddNewPracticePage() {
className="gap-1.5"
>
{uploadingIntroVideo ? (
-
+
) : (
)}
@@ -541,7 +542,7 @@ export function AddNewPracticePage() {
>
{importingIntroVideoUrl ? (
<>
-
+
Importing URL…
>
) : (
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
+
+ →
+
-
+ {
+ e.preventDefault()
+ e.stopPropagation()
+ setDeleteTarget(category)
+ }}
+ aria-label={`Delete category ${category.name}`}
+ >
+
+
+
@@ -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.`
+ : ""}
+
+
+
+ setDeleteTarget(null)} disabled={deleting}>
+ Cancel
+
+ {
+ if (!deleteTarget) return
+ setDeleting(true)
+ try {
+ await deleteCourseCategory(deleteTarget.id)
+ toast.success("Category deleted")
+ setDeleteTarget(null)
+ await fetchCategories()
+ } catch (err: any) {
+ const message = err?.response?.data?.message || "Failed to delete category."
+ toast.error("Could not delete category", { description: message })
+ } finally {
+ setDeleting(false)
+ }
+ }}
+ >
+ {deleting ? "Deleting..." : "Delete"}
+
+
+
+
)
}
diff --git a/src/pages/content-management/CourseFlowBuilderPage.tsx b/src/pages/content-management/CourseFlowBuilderPage.tsx
index f2e3bf4..69c69f4 100644
--- a/src/pages/content-management/CourseFlowBuilderPage.tsx
+++ b/src/pages/content-management/CourseFlowBuilderPage.tsx
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useState } from "react"
import {
- BadgeCheck,
ChevronDown,
ChevronRight,
GripVertical,
@@ -32,9 +31,9 @@ import { Badge } from "../../components/ui/badge"
import {
getCourseCategories,
getCoursesByCategory,
- getLearningPath,
+ getSubModulesByCourse,
+ getVideosBySubModule,
getQuestionSetsByOwner,
- getSubModuleEntryAssessment,
reorderCategories,
reorderCourses,
reorderSubModules,
@@ -194,9 +193,7 @@ export function CourseFlowBuilderPage() {
const [practicesBySubCourse, setPracticesBySubCourse] = useState>(
{},
)
- const [entryAssessmentBySubCourse, setEntryAssessmentBySubCourse] = useState>(
- {},
- )
+ const [videosBySubCourse, setVideosBySubCourse] = useState>({})
const [loading, setLoading] = useState(true)
const [loadingCourses, setLoadingCourses] = useState(false)
@@ -260,7 +257,9 @@ export function CourseFlowBuilderPage() {
setLoadingCourses(true)
try {
const res = await getCoursesByCategory(selectedCategoryId)
- const items = sortByDisplayOrder(res.data.data.courses ?? [])
+ const items = sortByDisplayOrder(
+ (res.data.data.courses ?? []).filter((course) => Number(course.category_id) === Number(selectedCategoryId)),
+ )
setCoursesByCategory((prev) => ({ ...prev, [selectedCategoryId]: items }))
setSelectedCourseId(items[0]?.id ?? null)
} catch {
@@ -280,47 +279,94 @@ export function CourseFlowBuilderPage() {
const load = async () => {
setLoadingPath(true)
try {
- const res = await getLearningPath(selectedCourseId)
- const path = res.data.data
+ const selectedCourse = activeCourses.find((course) => course.id === selectedCourseId)
+ const subRes = await getSubModulesByCourse(selectedCourseId)
+ const subCourses = sortByDisplayOrder((subRes.data.data.sub_courses ?? []) as any[]).map((sc) => ({
+ id: sc.id,
+ title: sc.title,
+ description: sc.description ?? "",
+ thumbnail: sc.thumbnail ?? "",
+ display_order: sc.display_order ?? 0,
+ level: sc.level ?? sc.cefr_level ?? "",
+ sub_level: sc.sub_level ?? "",
+ prerequisite_count: 0,
+ video_count: 0,
+ practice_count: 0,
+ prerequisites: [],
+ videos: [],
+ practices: [],
+ }))
+
setLearningPath({
- ...path,
- sub_courses: sortByDisplayOrder(path.sub_courses ?? []),
+ course_id: selectedCourseId,
+ course_title: selectedCourse?.title ?? "",
+ description: selectedCourse?.description ?? "",
+ thumbnail: selectedCourse?.thumbnail ?? "",
+ intro_video_url: "",
+ category_id: selectedCategoryId ?? 0,
+ category_name: topLevelCategories.find((cat) => cat.id === selectedCategoryId)?.name ?? "",
+ sub_courses: subCourses,
})
- // Practices source of truth: question sets by SUB_COURSE owner.
- const subCourses = path.sub_courses ?? []
- if (subCourses.length > 0) {
- const ownerResults = await Promise.all(
+ if (subCourses.length === 0) {
+ setPracticesBySubCourse({})
+ setVideosBySubCourse({})
+ return
+ }
+
+ const [ownerResults, videoResults] = await Promise.all([
+ Promise.all(
subCourses.map(async (sc) => {
- const setsRes = await getQuestionSetsByOwner("SUB_COURSE", sc.id)
+ const setsRes = await getQuestionSetsByOwner("SUB_MODULE", sc.id)
return [sc.id, mapPracticeSetsToPracticeItems((setsRes.data.data ?? []) as QuestionSet[])] as const
}),
- )
- const practiceMap: Record = {}
- ownerResults.forEach(([subCourseId, practiceItems]) => {
- practiceMap[subCourseId] = practiceItems
- })
- setPracticesBySubCourse(practiceMap)
- } else {
- setPracticesBySubCourse({})
- }
+ ),
+ Promise.all(
+ subCourses.map(async (sc) => {
+ const videosRes = await getVideosBySubModule(sc.id)
+ const rows = videosRes.data?.data?.videos ?? []
+ const mapped = sortByDisplayOrder(
+ rows.map((video: any, idx: number) => ({
+ id: Number(video.id),
+ title: String(video.title ?? "Video"),
+ display_order: Number(video.display_order ?? idx),
+ duration: Number(video.duration ?? 0),
+ video_url: String(video.video_url ?? ""),
+ })),
+ )
+ return [sc.id, mapped] as const
+ }),
+ ),
+ ])
+
+ const practiceMap: Record = {}
+ ownerResults.forEach(([subCourseId, practiceItems]) => {
+ practiceMap[subCourseId] = practiceItems
+ })
+ setPracticesBySubCourse(practiceMap)
+
+ const videoMap: Record = {}
+ videoResults.forEach(([subCourseId, videos]) => {
+ videoMap[subCourseId] = videos
+ })
+ setVideosBySubCourse(videoMap)
} catch {
- toast.error("Failed to load course sub-category learning path.")
+ toast.error("Failed to load course flow detail.")
setLearningPath(null)
} finally {
setLoadingPath(false)
}
}
load()
- }, [selectedCourseId])
+ }, [selectedCourseId, activeCourses, selectedCategoryId, topLevelCategories])
const loadSubCoursePracticeAndEntry = async (subCourseId: number) => {
- if (practicesBySubCourse[subCourseId] && entryAssessmentBySubCourse[subCourseId] !== undefined) return
+ if (practicesBySubCourse[subCourseId] && videosBySubCourse[subCourseId]) return
setLoadingPracticesBySubCourse((prev) => ({ ...prev, [subCourseId]: true }))
try {
- const [setsRes, entryRes] = await Promise.allSettled([
- getQuestionSetsByOwner("SUB_COURSE", subCourseId),
- getSubModuleEntryAssessment(subCourseId),
+ const [setsRes, videosRes] = await Promise.allSettled([
+ getQuestionSetsByOwner("SUB_MODULE", subCourseId),
+ getVideosBySubModule(subCourseId),
])
// No practice sets is a valid empty-state scenario; do not toast for 404/empty.
@@ -339,20 +385,21 @@ export function CourseFlowBuilderPage() {
[subCourseId]: mapPracticeSetsToPracticeItems(ownerSets),
}))
- // Entry assessment may legitimately be absent.
- let entryAssessment: QuestionSet | null = null
- if (entryRes.status === "fulfilled") {
- entryAssessment = (entryRes.value.data.data ?? null) as QuestionSet | null
- } else {
- const status = entryRes.reason?.response?.status
- if (status !== 404) {
- throw entryRes.reason
- }
- }
-
- setEntryAssessmentBySubCourse((prev) => ({
+ const videos =
+ videosRes.status === "fulfilled"
+ ? sortByDisplayOrder(
+ (videosRes.value.data?.data?.videos ?? []).map((video: any, idx: number) => ({
+ id: Number(video.id),
+ title: String(video.title ?? "Video"),
+ display_order: Number(video.display_order ?? idx),
+ duration: Number(video.duration ?? 0),
+ video_url: String(video.video_url ?? ""),
+ })),
+ )
+ : []
+ setVideosBySubCourse((prev) => ({
...prev,
- [subCourseId]: entryAssessment,
+ [subCourseId]: videos,
}))
} catch {
toast.error("Failed to load practice sets for course.")
@@ -694,6 +741,7 @@ export function CourseFlowBuilderPage() {
{learningPath.sub_courses.map((subCourse) => {
const expanded = expandedSubCourseIds.has(subCourse.id)
const practices = practicesBySubCourse[subCourse.id] ?? []
+ const videos = videosBySubCourse[subCourse.id] ?? subCourse.videos ?? []
return (
)}
- {entryAssessmentBySubCourse[subCourse.id] && (
-
-
- Entry assessment
-
- )}
+ {/* entry-assessment route is no longer guaranteed across deployments */}
- {subCourse.videos.length} videos / {practices.length || subCourse.practice_count} practices
+ {videos.length} videos / {practices.length} practices
{expanded ? (
@@ -755,16 +798,16 @@ export function CourseFlowBuilderPage() {
onDragEnd={(event) => onVideosDragEnd(subCourse.id, event)}
>
item.id)}
+ items={videos.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
- {subCourse.videos.length === 0 ? (
+ {videos.length === 0 ? (
No videos
) : (
- subCourse.videos.map((video) => (
+ videos.map((video) => (
Practices load from /question-sets/by-owner filtered by
- set_type=PRACTICE; entry assessment loads from dedicated course endpoint.
+ set_type=PRACTICE and owner_type=SUB_MODULE.
diff --git a/src/pages/content-management/CoursesPage.tsx b/src/pages/content-management/CoursesPage.tsx
index e081157..f5559dc 100644
--- a/src/pages/content-management/CoursesPage.tsx
+++ b/src/pages/content-management/CoursesPage.tsx
@@ -1,14 +1,27 @@
import { useEffect, useMemo, useState } from "react"
import { Link, useParams, useNavigate } from "react-router-dom"
-import { Plus, ArrowLeft, ToggleLeft, ToggleRight, X, Trash2, Edit, AlertCircle, Star, MessageSquare, ChevronDown, ChevronLeft, ChevronRight, Search } from "lucide-react"
+import {
+ Plus,
+ ArrowLeft,
+ ToggleLeft,
+ ToggleRight,
+ X,
+ Trash2,
+ Edit,
+ AlertCircle,
+ ChevronDown,
+ ChevronLeft,
+ ChevronRight,
+ Search,
+ BookOpen,
+ Eye,
+} from "lucide-react"
import practiceSrc from "../../assets/Practice.svg"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
-import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import alertSrc from "../../assets/Alert.svg"
import { Button } from "../../components/ui/button"
import { Badge } from "../../components/ui/badge"
import { Input } from "../../components/ui/input"
-import { FileUpload } from "../../components/ui/file-upload"
import {
Table,
TableBody,
@@ -18,24 +31,19 @@ import {
TableRow,
} from "../../components/ui/table"
import {
- getCoursesByCategory,
+ getSubCategoriesByCategoryId,
getCourseCategories,
- createCourse,
- deleteCourse,
- updateCourseStatus,
- updateCourse,
- updateCourseThumbnail,
- getRatings,
+ createSubCategory,
+ deleteCourseSubCategory,
+ updateSubCategory,
} from "../../api/courses.api"
-import { uploadImageFile } from "../../api/files.api"
-import type { Course, CourseCategory, Rating } from "../../types/course.types"
+import type { CategorySubCategoryListItem, CourseCategory } from "../../types/course.types"
import { cn } from "../../lib/utils"
-import { SpinnerIcon } from "../../components/ui/spinner-icon"
export function CoursesPage() {
const { categoryId } = useParams<{ categoryId: string }>()
const navigate = useNavigate()
- const [courses, setCourses] = useState([])
+ const [subCategories, setSubCategories] = useState([])
const [searchQuery, setSearchQuery] = useState("")
const [category, setCategory] = useState(null)
const [loading, setLoading] = useState(true)
@@ -44,36 +52,32 @@ export function CoursesPage() {
const [showModal, setShowModal] = useState(false)
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
+ const [displayOrder, setDisplayOrder] = useState("")
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState(null)
const [showDeleteModal, setShowDeleteModal] = useState(false)
- const [courseToDelete, setCourseToDelete] = useState(null)
+ const [subCategoryToDelete, setSubCategoryToDelete] = useState(null)
const [deleting, setDeleting] = useState(false)
const [togglingId, setTogglingId] = useState(null)
const [showEditModal, setShowEditModal] = useState(false)
- const [courseToEdit, setCourseToEdit] = useState(null)
+ const [subCategoryToEdit, setSubCategoryToEdit] = useState(null)
const [editTitle, setEditTitle] = useState("")
const [editDescription, setEditDescription] = useState("")
- const [editThumbnail, setEditThumbnail] = useState("")
- const [editThumbnailFile, setEditThumbnailFile] = useState(null)
+ const [editDisplayOrder, setEditDisplayOrder] = useState(0)
const [updating, setUpdating] = useState(false)
const [updateError, setUpdateError] = useState(null)
- const [showRatingsModal, setShowRatingsModal] = useState(false)
- const [ratingsCourseId, setRatingsCourseId] = useState(null)
- const [courseRatings, setCourseRatings] = useState([])
- const [courseRatingsLoading, setCourseRatingsLoading] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
- const fetchCourses = async () => {
+ const fetchSubCategories = async () => {
if (!categoryId) return
try {
- const coursesRes = await getCoursesByCategory(Number(categoryId))
- console.log("Courses response:", coursesRes.data.data.courses)
- setCourses(coursesRes.data.data.courses ?? [])
+ const res = await getSubCategoriesByCategoryId(Number(categoryId))
+ const raw = res.data?.data?.sub_categories
+ setSubCategories(Array.isArray(raw) ? raw : [])
} catch (err) {
- console.error("Failed to fetch courses:", err)
+ console.error("Failed to fetch sub-categories:", err)
}
}
@@ -82,18 +86,19 @@ export function CoursesPage() {
if (!categoryId) return
try {
- const [coursesRes, categoriesRes] = await Promise.all([
- getCoursesByCategory(Number(categoryId)),
+ const [subRes, categoriesRes] = await Promise.all([
+ getSubCategoriesByCategoryId(Number(categoryId)),
getCourseCategories(),
])
- setCourses(coursesRes.data.data.courses ?? [])
- const foundCategory = categoriesRes.data.data.categories.find(
- (c) => c.id === Number(categoryId)
+ const raw = subRes.data?.data?.sub_categories
+ setSubCategories(Array.isArray(raw) ? raw : [])
+ const foundCategory = categoriesRes.data?.data?.categories?.find(
+ (c) => c.id === Number(categoryId),
)
setCategory(foundCategory ?? null)
} catch (err) {
- console.error("Failed to fetch courses:", err)
+ console.error("Failed to fetch sub-categories:", err)
setError("Failed to load sub-categories")
} finally {
setLoading(false)
@@ -110,6 +115,7 @@ export function CoursesPage() {
const handleOpenModal = () => {
setTitle("")
setDescription("")
+ setDisplayOrder("")
setSaveError(null)
setShowModal(true)
}
@@ -118,16 +124,13 @@ export function CoursesPage() {
setShowModal(false)
setTitle("")
setDescription("")
+ setDisplayOrder("")
setSaveError(null)
}
const handleSave = async () => {
if (!title.trim()) {
- setSaveError("Title is required")
- return
- }
- if (!description.trim()) {
- setSaveError("Description is required")
+ setSaveError("Name is required")
return
}
@@ -135,13 +138,15 @@ export function CoursesPage() {
setSaveError(null)
try {
- await createCourse({
+ const orderParsed = parseInt(displayOrder.trim(), 10)
+ await createSubCategory({
category_id: Number(categoryId),
- title: title.trim(),
- description: description.trim(),
+ name: title.trim(),
+ description: description.trim() || null,
+ ...(Number.isFinite(orderParsed) && orderParsed >= 0 ? { display_order: orderParsed } : {}),
})
handleCloseModal()
- await fetchCourses()
+ await fetchSubCategories()
} catch (err: any) {
console.error("Failed to create course:", err)
setSaveError(err.response?.data?.message || "Failed to create sub-category")
@@ -150,20 +155,20 @@ export function CoursesPage() {
}
}
- const handleDeleteClick = (course: Course) => {
- setCourseToDelete(course)
+ const handleDeleteClick = (sub: CategorySubCategoryListItem) => {
+ setSubCategoryToDelete(sub)
setShowDeleteModal(true)
}
const handleConfirmDelete = async () => {
- if (!courseToDelete) return
+ if (!subCategoryToDelete) return
setDeleting(true)
try {
- await deleteCourse(courseToDelete.id)
+ await deleteCourseSubCategory(subCategoryToDelete.id)
setShowDeleteModal(false)
- setCourseToDelete(null)
- await fetchCourses()
+ setSubCategoryToDelete(null)
+ await fetchSubCategories()
} catch (err) {
console.error("Failed to delete course:", err)
} finally {
@@ -171,11 +176,11 @@ export function CoursesPage() {
}
}
- const handleToggleStatus = async (course: Course) => {
- setTogglingId(course.id)
+ const handleToggleStatus = async (sub: CategorySubCategoryListItem) => {
+ setTogglingId(sub.id)
try {
- await updateCourseStatus(course.id, !course.is_active)
- await fetchCourses()
+ await updateSubCategory(sub.id, { is_active: !sub.is_active })
+ await fetchSubCategories()
} catch (err) {
console.error("Failed to update course status:", err)
} finally {
@@ -183,35 +188,29 @@ export function CoursesPage() {
}
}
- const handleEditClick = (course: Course) => {
- setCourseToEdit(course)
- setEditTitle(course.title || "")
- setEditDescription(course.description || "")
- setEditThumbnail(course.thumbnail || "")
- setEditThumbnailFile(null)
+ const handleEditClick = (sub: CategorySubCategoryListItem) => {
+ setSubCategoryToEdit(sub)
+ setEditTitle(sub.name || "")
+ setEditDescription(sub.description ?? "")
+ setEditDisplayOrder(sub.display_order ?? 0)
setUpdateError(null)
setShowEditModal(true)
}
const handleCloseEditModal = () => {
setShowEditModal(false)
- setCourseToEdit(null)
+ setSubCategoryToEdit(null)
setEditTitle("")
setEditDescription("")
- setEditThumbnail("")
- setEditThumbnailFile(null)
+ setEditDisplayOrder(0)
setUpdateError(null)
}
const handleUpdate = async () => {
- if (!courseToEdit) return
+ if (!subCategoryToEdit) return
if (!editTitle.trim()) {
- setUpdateError("Title is required")
- return
- }
- if (!editDescription.trim()) {
- setUpdateError("Description is required")
+ setUpdateError("Name is required")
return
}
@@ -219,23 +218,15 @@ export function CoursesPage() {
setUpdateError(null)
try {
- await updateCourse(courseToEdit.id, {
- title: editTitle.trim(),
- description: editDescription.trim(),
- is_active: courseToEdit.is_active,
+ await updateSubCategory(subCategoryToEdit.id, {
+ name: editTitle.trim(),
+ description: editDescription.trim() || null,
+ display_order: Math.max(0, Number(editDisplayOrder) || 0),
+ is_active: subCategoryToEdit.is_active,
})
- const thumbnailUrl =
- editThumbnailFile
- ? (await uploadImageFile(editThumbnailFile)).data?.data?.url?.trim()
- : editThumbnail.trim() || ""
-
- if (thumbnailUrl) {
- await updateCourseThumbnail(courseToEdit.id, thumbnailUrl)
- }
-
handleCloseEditModal()
- await fetchCourses()
+ await fetchSubCategories()
} catch (err: any) {
console.error("Failed to update course:", err)
setUpdateError(err.response?.data?.message || "Failed to update sub-category")
@@ -244,32 +235,19 @@ export function CoursesPage() {
}
}
- const handleCourseClick = (courseId: number) => {
- navigate(`/content/category/${categoryId}/courses/${courseId}/sub-modules`)
+ const handleOpenSubCategory = (subCategoryId: number) => {
+ navigate(`/content/category/${categoryId}/sub-categories/${subCategoryId}/courses`)
}
- const handleViewRatings = async (courseId: number) => {
- setRatingsCourseId(courseId)
- setShowRatingsModal(true)
- setCourseRatingsLoading(true)
- try {
- const res = await getRatings({ target_type: "course", target_id: courseId, limit: 10 })
- setCourseRatings(res.data.data ?? [])
- } catch (err) {
- console.error("Failed to fetch ratings:", err)
- } finally {
- setCourseRatingsLoading(false)
- }
- }
-
- const filteredCourses = useMemo(() => {
+ const filteredSubCategories = useMemo(() => {
const q = searchQuery.trim().toLowerCase()
- if (!q) return courses
- return courses.filter((course) => {
- const haystack = `${course.title} ${course.description ?? ""} ${course.id}`.toLowerCase()
+ if (!q) return subCategories
+ return subCategories.filter((sub) => {
+ const haystack =
+ `${sub.name} ${sub.description ?? ""} ${sub.category_name} ${sub.id} ${sub.display_order}`.toLowerCase()
return haystack.includes(q)
})
- }, [courses, searchQuery])
+ }, [subCategories, searchQuery])
if (loading) {
return (
@@ -290,13 +268,29 @@ export function CoursesPage() {
)
}
- const totalCount = filteredCourses.length
+ const totalCount = filteredSubCategories.length
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
const safePage = Math.min(page, totalPages)
- const paginatedCourses = filteredCourses.slice((safePage - 1) * pageSize, safePage * pageSize)
+ const paginatedSubCategories = filteredSubCategories.slice((safePage - 1) * pageSize, safePage * pageSize)
const startEntry = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1
const endEntry = Math.min(safePage * pageSize, totalCount)
+ const formatId = (id: number) => `#${id}`
+
+ const formatCreatedAt = (iso: string) => {
+ try {
+ return new Date(iso).toLocaleString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ })
+ } catch {
+ return iso
+ }
+ }
+
const getPageNumbers = () => {
const pages: (number | string)[] = []
if (totalPages <= 7) {
@@ -328,7 +322,7 @@ export function CoursesPage() {
{category?.name} Sub-categories
- {courses.length} sub-categories available
+ {subCategories.length} sub-categories available
@@ -339,219 +333,246 @@ export function CoursesPage() {
- {/* Course table or empty state */}
-
-
-
-
- Sub-category Management
-
-
-
- setSearchQuery(e.target.value)}
- placeholder="Search sub-categories..."
- className="pl-9"
- />
-
-
-
-
- {courses.length === 0 ? (
-
-
-
No sub-categories yet
-
- No sub-categories found in this category.
-
-
-
- Add your first sub-category
-
-
- ) : filteredCourses.length === 0 ? (
-
-
No matching sub-categories
-
- Try a different search term.
-
-
- ) : (
-
-
-
-
- Sub-category
- Status
- Actions
-
-
-
- {paginatedCourses.map((course) => (
- handleCourseClick(course.id)}
- >
-
-
- {course.title}
-
- {course.description && (
-
- {course.description}
-
- )}
-
-
-
- {course.is_active ? "Active" : "Inactive"}
-
-
-
-
- {
- e.stopPropagation()
- handleViewRatings(course.id)
- }}
- >
-
-
- {
- e.stopPropagation()
- handleEditClick(course)
- }}
- >
-
-
- {
- e.stopPropagation()
- handleToggleStatus(course)
- }}
- >
- {course.is_active ? (
-
- ) : (
-
- )}
-
- {
- e.stopPropagation()
- handleDeleteClick(course)
- }}
- >
-
-
-
-
-
- ))}
-
-
+ {/* Sub-category table — layout aligned with Activity Log (UserLogPage) */}
+
+
Sub-category Management
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="Search sub-categories..."
+ className="pl-9"
+ />
+
+
-
-
-
Showing
-
- {startEntry}-{endEntry}
-
-
of
-
{totalCount}
-
entries
-
Rows per page
-
- {
- setPageSize(Number(e.target.value))
- setPage(1)
- }}
- className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
- >
- {[10, 20, 50].map((size) => (
-
- {size}
-
- ))}
-
-
-
-
-
-
safePage > 1 && setPage(safePage - 1)}
- disabled={safePage === 1}
- className={cn(
- "flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
- safePage === 1 && "cursor-not-allowed opacity-50",
- )}
+ {subCategories.length === 0 ? (
+
+
+
+
No sub-categories yet
+
No sub-categories found in this category.
+
+
+ Add your first sub-category
+
+
+
+ ) : (
+
+
+
+
+ ID
+ SUB-CATEGORY
+ CATEGORY
+ DESCRIPTION
+ ORDER
+ CATEGORY ID
+ CREATED
+ STATUS
+ ACTIONS
+
+
+
+ {filteredSubCategories.length === 0 ? (
+
+
+
+
+
+
No matching sub-categories
+
Try a different search term.
+
+
+
+
+ ) : (
+ paginatedSubCategories.map((sub) => (
+ handleOpenSubCategory(sub.id)}
>
-
-
- {getPageNumbers().map((n, idx) =>
- typeof n === "string" ? (
-
- ...
-
- ) : (
- setPage(n)}
- className={cn(
- "h-8 w-8 rounded-md border text-sm font-medium",
- n === safePage
- ? "border-brand-500 bg-brand-500 text-white"
- : "bg-white text-grayScale-600 hover:bg-grayScale-50",
- )}
+ {formatId(sub.id)}
+
+
+
+
+ {sub.category_name}
+
+
+
+ {sub.description?.trim() ? sub.description : "—"}
+
+
+
+ {sub.display_order}
+
+
+ {formatId(sub.category_id)}
+
+
+ {formatCreatedAt(sub.created_at)}
+
+
+
- {n}
-
- ),
- )}
- safePage < totalPages && setPage(safePage + 1)}
- disabled={safePage === totalPages}
- className={cn(
- "flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
- safePage === totalPages && "cursor-not-allowed opacity-50",
- )}
+ {sub.is_active ? "Active" : "Inactive"}
+
+
+
+
+ {
+ e.stopPropagation()
+ handleOpenSubCategory(sub.id)
+ }}
+ title="View courses"
+ >
+
+
+ {
+ e.stopPropagation()
+ handleEditClick(sub)
+ }}
+ >
+
+
+ {
+ e.stopPropagation()
+ handleToggleStatus(sub)
+ }}
+ >
+ {sub.is_active ? (
+
+ ) : (
+
+ )}
+
+ {
+ e.stopPropagation()
+ handleDeleteClick(sub)
+ }}
+ >
+
+
+
+
+
+ ))
+ )}
+
+
+
+ {totalCount > 0 ? (
+
+
+
Showing
+
+ {startEntry}–{endEntry}
+
+
of
+
{totalCount}
+
entries
+
Rows per page
+
+ {
+ setPageSize(Number(e.target.value))
+ setPage(1)
+ }}
+ className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
-
-
+ {[10, 20, 50].map((size) => (
+
+ {size}
+
+ ))}
+
+
+
+ safePage > 1 && setPage(safePage - 1)}
+ disabled={safePage === 1}
+ className={cn(
+ "flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
+ safePage === 1 && "cursor-not-allowed opacity-50",
+ )}
+ >
+
+
+ {getPageNumbers().map((n, idx) =>
+ typeof n === "string" ? (
+
+ ...
+
+ ) : (
+ setPage(n)}
+ className={cn(
+ "h-8 w-8 rounded-md border text-sm font-medium",
+ n === safePage
+ ? "border-brand-500 bg-brand-500 text-white"
+ : "bg-white text-grayScale-600 hover:bg-grayScale-50",
+ )}
+ >
+ {n}
+
+ ),
+ )}
+ safePage < totalPages && setPage(safePage + 1)}
+ disabled={safePage === totalPages}
+ className={cn(
+ "flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
+ safePage === totalPages && "cursor-not-allowed opacity-50",
+ )}
+ >
+
+
+
- )}
-
-
+ ) : null}
+
+ )}
- {/* Add Course Modal */}
+ {/* Add Sub-category Modal */}
{showModal && (
@@ -575,14 +596,14 @@ export function CoursesPage() {
- Title *
+ Name *
setTitle(e.target.value)}
/>
@@ -590,14 +611,14 @@ export function CoursesPage() {
- Description *
+ Description
+
+
+ Display order
+
+ setDisplayOrder(e.target.value)}
+ />
+
+
Category: {category?.name}
@@ -626,8 +661,8 @@ export function CoursesPage() {
)}
- {/* Edit Course Modal */}
- {showEditModal && courseToEdit && (
+ {/* Edit Sub-category Modal */}
+ {showEditModal && subCategoryToEdit && (
@@ -650,14 +685,14 @@ export function CoursesPage() {
@@ -721,122 +745,8 @@ export function CoursesPage() {
)}
- {/* Ratings Modal */}
- {showRatingsModal && (
-
-
-
-
Sub-category Ratings
- {
- setShowRatingsModal(false)
- setRatingsCourseId(null)
- setCourseRatings([])
- }}
- className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
- >
-
-
-
-
-
- {courseRatingsLoading ? (
-
- ) : courseRatings.length === 0 ? (
-
-
-
-
-
No ratings yet
-
- Ratings will appear here once learners start reviewing this sub-category.
-
-
- ) : (
-
- {/* Summary bar */}
-
-
-
-
- {(courseRatings.reduce((sum, r) => sum + r.stars, 0) / courseRatings.length).toFixed(1)}
-
- / 5
-
-
-
- {courseRatings.length} review{courseRatings.length !== 1 ? "s" : ""}
-
-
-
- {/* Rating cards */}
-
- {courseRatings.map((rating) => (
-
- {/* Header row */}
-
-
-
- U{rating.user_id}
-
-
-
User #{rating.user_id}
-
- {new Date(rating.created_at).toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- year: "numeric",
- })}
- {rating.updated_at !== rating.created_at && (
- · edited
- )}
-
-
-
-
- {/* Stars */}
-
- {[1, 2, 3, 4, 5].map((s) => (
-
- ))}
-
-
-
- {/* Review text */}
- {rating.review && (
-
-
-
- {rating.review}
-
-
- )}
-
- ))}
-
-
- )}
-
-
-
- )}
-
- {/* Delete Course Modal */}
- {showDeleteModal && courseToDelete && (
+ {/* Delete Sub-category Modal */}
+ {showDeleteModal && subCategoryToDelete && (
@@ -855,7 +765,7 @@ export function CoursesPage() {
Are you sure you want to delete{" "}
- {courseToDelete.title} ? This action cannot
+ {subCategoryToDelete.name} ? This action cannot
be undone.
diff --git a/src/pages/content-management/HumanLanguageHierarchyPage.tsx b/src/pages/content-management/HumanLanguageHierarchyPage.tsx
new file mode 100644
index 0000000..8b604af
--- /dev/null
+++ b/src/pages/content-management/HumanLanguageHierarchyPage.tsx
@@ -0,0 +1,1310 @@
+import { type Dispatch, type SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { Link } from "react-router-dom"
+import { BookOpen, ChevronDown, ChevronRight, FolderTree, Languages, Pencil, Plus, Trash2 } from "lucide-react"
+import { toast } from "sonner"
+import { Badge } from "../../components/ui/badge"
+import { Button } from "../../components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../components/ui/dialog"
+import { Input } from "../../components/ui/input"
+import { Select } from "../../components/ui/select"
+import { SpinnerIcon } from "../../components/ui/spinner-icon"
+import { Textarea } from "../../components/ui/textarea"
+import {
+ createModule,
+ deleteModule,
+ getCourseHierarchyByCourseId,
+ getHumanLanguageHierarchy,
+ getPracticesByLevel,
+ updateModule,
+} from "../../api/courses.api"
+import { uploadImageFile } from "../../api/files.api"
+import type { CourseHierarchyRow, HumanLanguageHierarchyFlatRow, Practice } from "../../types/course.types"
+
+type IdFilterValue = number | "ALL"
+type LevelFilterValue = number | "ALL"
+
+type SubCategoryOption = { id: number; name: string; category_id: number; category_name: string }
+type CourseOption = { id: number; title: string }
+type SubModuleNode = { id: number; title: string; display_order: number | null }
+type ModuleNode = { id: number; title: string; icon_url?: string | null; sub_modules: SubModuleNode[] }
+type LevelNode = { id: number; cefr_level: string; title: string; modules: ModuleNode[] }
+type CourseTreeNode = { course_id: number; course_title: string; levels: LevelNode[] }
+type DeleteModuleTarget = {
+ courseId: number
+ moduleId: number
+ moduleTitle: string
+ levelTitle: string
+ moduleKey: string
+}
+type EditModuleTarget = {
+ courseId: number
+ moduleId: number
+ moduleKey: string
+ levelKey: string
+}
+type HierarchyReturnState = {
+ selectedSubCategoryId: IdFilterValue
+ selectedCourseId: IdFilterValue
+ selectedLevelId: LevelFilterValue
+ expandedCourses: string[]
+ expandedLevels: string[]
+ expandedModules: string[]
+ scrollY: number
+}
+
+const HUMAN_LANGUAGE_RETURN_STATE_KEY = "humanLanguageHierarchyReturnState"
+
+const CEFR_ORDER = ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3"]
+const textCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" })
+
+const setHas = (set: Set
, key: string) => set.has(key)
+const toggleSetValue = (setState: Dispatch>>, key: string) => {
+ setState((previous) => {
+ const next = new Set(previous)
+ if (next.has(key)) next.delete(key)
+ else next.add(key)
+ return next
+ })
+}
+
+function cefrSortValue(level: string) {
+ const idx = CEFR_ORDER.indexOf(level.trim().toUpperCase())
+ return idx === -1 ? Number.MAX_SAFE_INTEGER : idx
+}
+
+function toSubCategoryOptions(rows: HumanLanguageHierarchyFlatRow[]): SubCategoryOption[] {
+ const map = new Map()
+ rows.forEach((row) => {
+ if (!row.sub_category_id || map.has(row.sub_category_id)) return
+ map.set(row.sub_category_id, {
+ id: row.sub_category_id,
+ name: row.sub_category_name ?? "Unnamed sub-category",
+ category_id: row.category_id,
+ category_name: row.category_name,
+ })
+ })
+ return Array.from(map.values()).sort((a, b) => textCollator.compare(a.name, b.name) || a.id - b.id)
+}
+
+function toCourseOptions(rows: HumanLanguageHierarchyFlatRow[], subCategoryId: number): CourseOption[] {
+ const map = new Map()
+ rows
+ .filter((row) => row.sub_category_id === subCategoryId && !!row.course_id)
+ .forEach((row) => {
+ if (!row.course_id || map.has(row.course_id)) return
+ map.set(row.course_id, { id: row.course_id, title: row.course_title ?? `Course ${row.course_id}` })
+ })
+ return Array.from(map.values()).sort((a, b) => textCollator.compare(a.title, b.title) || a.id - b.id)
+}
+
+function toLevelNodes(rows: CourseHierarchyRow[]): LevelNode[] {
+ const levelMap = new Map }>()
+ rows.forEach((row) => {
+ if (!row.level_id) return
+ const levelId = Number(row.level_id)
+ const cefr = (row.cefr_level ?? "").trim().toUpperCase()
+ if (!levelMap.has(levelId)) {
+ levelMap.set(levelId, {
+ cefr_level: cefr,
+ title: row.level_title?.trim() || cefr || `Level ${levelId}`,
+ modules: new Map(),
+ })
+ }
+
+ if (!row.module_id) return
+ const moduleId = Number(row.module_id)
+ const levelNode = levelMap.get(levelId)!
+ if (!levelNode.modules.has(moduleId)) {
+ levelNode.modules.set(moduleId, {
+ id: moduleId,
+ title: row.module_title?.trim() || `Module ${moduleId}`,
+ icon_url: row.module_icon_url ?? null,
+ sub_modules: [],
+ })
+ } else if (row.module_icon_url && !levelNode.modules.get(moduleId)?.icon_url) {
+ levelNode.modules.set(moduleId, {
+ ...levelNode.modules.get(moduleId)!,
+ icon_url: row.module_icon_url,
+ })
+ }
+
+ if (!row.sub_module_id) return
+ const moduleNode = levelNode.modules.get(moduleId)!
+ const subModuleId = Number(row.sub_module_id)
+ if (moduleNode.sub_modules.some((item) => item.id === subModuleId)) return
+ moduleNode.sub_modules.push({
+ id: subModuleId,
+ title: row.sub_module_title?.trim() || `Sub-module ${subModuleId}`,
+ display_order: row.sub_module_display_order ?? null,
+ })
+ })
+
+ return Array.from(levelMap.entries())
+ .map(([id, level]) => ({
+ id,
+ cefr_level: level.cefr_level,
+ title: level.title,
+ modules: Array.from(level.modules.values())
+ .map((module) => ({
+ ...module,
+ sub_modules: [...module.sub_modules].sort((a, b) => {
+ const ao = a.display_order ?? Number.MAX_SAFE_INTEGER
+ const bo = b.display_order ?? Number.MAX_SAFE_INTEGER
+ return ao - bo || textCollator.compare(a.title, b.title) || a.id - b.id
+ }),
+ }))
+ .sort((a, b) => textCollator.compare(a.title, b.title) || a.id - b.id),
+ }))
+ .sort((a, b) => {
+ const byCefr = cefrSortValue(a.cefr_level) - cefrSortValue(b.cefr_level)
+ return byCefr || textCollator.compare(a.title, b.title) || a.id - b.id
+ })
+}
+
+function getNextDefaultModuleName(level: LevelNode) {
+ const usedMinorNumbers = new Set()
+ level.modules.forEach((module) => {
+ const match = module.title.trim().match(/^module-\s*1\.(\d+)$/i)
+ if (!match) return
+ const minor = Number(match[1])
+ if (Number.isFinite(minor) && minor > 0) {
+ usedMinorNumbers.add(minor)
+ }
+ })
+
+ let nextMinor = 1
+ while (usedMinorNumbers.has(nextMinor)) {
+ nextMinor += 1
+ }
+ return `Module-1.${nextMinor}`
+}
+
+export function HumanLanguageHierarchyPage() {
+ const readPersistedReturnState = (): HierarchyReturnState | null => {
+ try {
+ const raw = window.sessionStorage.getItem(HUMAN_LANGUAGE_RETURN_STATE_KEY)
+ if (!raw) return null
+ const parsed = JSON.parse(raw) as Partial
+ return {
+ selectedSubCategoryId: parsed.selectedSubCategoryId ?? "ALL",
+ selectedCourseId: parsed.selectedCourseId ?? "ALL",
+ selectedLevelId: parsed.selectedLevelId ?? "ALL",
+ expandedCourses: Array.isArray(parsed.expandedCourses) ? parsed.expandedCourses : [],
+ expandedLevels: Array.isArray(parsed.expandedLevels) ? parsed.expandedLevels : [],
+ expandedModules: Array.isArray(parsed.expandedModules) ? parsed.expandedModules : [],
+ scrollY: Number.isFinite(parsed.scrollY) ? Number(parsed.scrollY) : 0,
+ }
+ } catch {
+ return null
+ }
+ }
+
+ const persistedReturnStateRef = useRef(readPersistedReturnState())
+ const skipInitialSubCategoryResetRef = useRef(!!persistedReturnStateRef.current)
+ const skipFilterValidationRef = useRef(!!persistedReturnStateRef.current)
+ const pendingRestoreScrollRef = useRef(persistedReturnStateRef.current?.scrollY ?? null)
+
+ const [hierarchyRows, setHierarchyRows] = useState([])
+ const [hierarchyLoading, setHierarchyLoading] = useState(true)
+ const [hierarchyError, setHierarchyError] = useState(null)
+
+ const [selectedSubCategoryId, setSelectedSubCategoryId] = useState(
+ persistedReturnStateRef.current?.selectedSubCategoryId ?? "ALL",
+ )
+ const [selectedCourseId, setSelectedCourseId] = useState(
+ persistedReturnStateRef.current?.selectedCourseId ?? "ALL",
+ )
+ const [selectedLevelId, setSelectedLevelId] = useState(
+ persistedReturnStateRef.current?.selectedLevelId ?? "ALL",
+ )
+
+ const [courseRowsByCourseId, setCourseRowsByCourseId] = useState>({})
+ const [courseHierarchyLoading, setCourseHierarchyLoading] = useState(false)
+ const [courseHierarchyError, setCourseHierarchyError] = useState(null)
+ const [levelPracticesByLevelId, setLevelPracticesByLevelId] = useState>({})
+
+ const [expandedCourses, setExpandedCourses] = useState>(
+ () => new Set(persistedReturnStateRef.current?.expandedCourses ?? []),
+ )
+ const [expandedLevels, setExpandedLevels] = useState>(
+ () => new Set(persistedReturnStateRef.current?.expandedLevels ?? []),
+ )
+ const [expandedModules, setExpandedModules] = useState>(
+ () => new Set(persistedReturnStateRef.current?.expandedModules ?? []),
+ )
+ const [createModuleOpen, setCreateModuleOpen] = useState(false)
+ const [createModuleSaving, setCreateModuleSaving] = useState(false)
+ const [createModuleCourseId, setCreateModuleCourseId] = useState(null)
+ const [createModuleLevelId, setCreateModuleLevelId] = useState(null)
+ const [createModuleLevelKey, setCreateModuleLevelKey] = useState("")
+ const [createModuleTitle, setCreateModuleTitle] = useState("")
+ const [createModuleUseDefaultNaming, setCreateModuleUseDefaultNaming] = useState(false)
+ const [createModuleDefaultTitle, setCreateModuleDefaultTitle] = useState("")
+ const [createModuleDescription, setCreateModuleDescription] = useState("")
+ const [createModuleIconSource, setCreateModuleIconSource] = useState<"url" | "file">("url")
+ const [createModuleIconUrl, setCreateModuleIconUrl] = useState("")
+ const [createModuleIconFile, setCreateModuleIconFile] = useState(null)
+ const [createModuleDisplayOrder, setCreateModuleDisplayOrder] = useState(0)
+ const [deleteModuleSavingId, setDeleteModuleSavingId] = useState(null)
+ const [deleteModuleDialogOpen, setDeleteModuleDialogOpen] = useState(false)
+ const [deleteModuleTarget, setDeleteModuleTarget] = useState(null)
+ const [editModuleDialogOpen, setEditModuleDialogOpen] = useState(false)
+ const [editModuleSaving, setEditModuleSaving] = useState(false)
+ const [editModuleTarget, setEditModuleTarget] = useState(null)
+ const [editModuleTitle, setEditModuleTitle] = useState("")
+ const [editModuleDescription, setEditModuleDescription] = useState("")
+ const [editModuleDisplayOrder, setEditModuleDisplayOrder] = useState(0)
+ const [editModuleIconSource, setEditModuleIconSource] = useState<"url" | "file">("url")
+ const [editModuleIconUrl, setEditModuleIconUrl] = useState("")
+ const [editModuleOriginalIconUrl, setEditModuleOriginalIconUrl] = useState("")
+ const [editModuleIconFile, setEditModuleIconFile] = useState(null)
+
+ const subCategoryOptions = useMemo(() => toSubCategoryOptions(hierarchyRows), [hierarchyRows])
+ const selectedSubCategory = useMemo(
+ () => (selectedSubCategoryId === "ALL" ? null : subCategoryOptions.find((item) => item.id === selectedSubCategoryId) ?? null),
+ [selectedSubCategoryId, subCategoryOptions],
+ )
+ const courseOptions = useMemo(
+ () => (selectedSubCategoryId === "ALL" ? [] : toCourseOptions(hierarchyRows, selectedSubCategoryId)),
+ [hierarchyRows, selectedSubCategoryId],
+ )
+ const selectedCourse = useMemo(
+ () => (selectedCourseId === "ALL" ? null : courseOptions.find((item) => item.id === selectedCourseId) ?? null),
+ [selectedCourseId, courseOptions],
+ )
+
+ const targetCourseIds = useMemo(() => {
+ if (selectedSubCategoryId === "ALL") return []
+ if (selectedCourseId !== "ALL") return [selectedCourseId]
+ return courseOptions.map((course) => course.id)
+ }, [selectedSubCategoryId, selectedCourseId, courseOptions])
+
+ const courseTrees = useMemo(() => {
+ const targetSet = new Set(targetCourseIds)
+ return courseOptions
+ .filter((course) => targetSet.has(course.id))
+ .map((course) => {
+ const levels = toLevelNodes(courseRowsByCourseId[course.id] ?? [])
+ return {
+ course_id: course.id,
+ course_title: course.title,
+ levels: selectedLevelId === "ALL" ? levels : levels.filter((level) => level.id === selectedLevelId),
+ }
+ })
+ }, [courseOptions, courseRowsByCourseId, targetCourseIds, selectedLevelId])
+
+ const fetchHumanLanguageHierarchy = useCallback(async () => {
+ setHierarchyLoading(true)
+ setHierarchyError(null)
+ try {
+ const res = await getHumanLanguageHierarchy()
+ setHierarchyRows(res.data?.data ?? [])
+ } catch (err) {
+ console.error(err)
+ setHierarchyRows([])
+ setHierarchyError("Could not load Human Language hierarchy")
+ toast.error("Failed to load Human Language hierarchy")
+ } finally {
+ setHierarchyLoading(false)
+ }
+ }, [])
+
+ const fetchHierarchiesForCourses = useCallback(async (courseIds: number[]) => {
+ if (courseIds.length === 0) {
+ setCourseRowsByCourseId({})
+ setLevelPracticesByLevelId({})
+ setCourseHierarchyError(null)
+ return
+ }
+
+ setCourseHierarchyLoading(true)
+ setCourseHierarchyError(null)
+ try {
+ const courseEntries = await Promise.all(
+ courseIds.map(async (courseId) => {
+ try {
+ const response = await getCourseHierarchyByCourseId(courseId)
+ return [courseId, response.data?.data ?? []] as const
+ } catch {
+ return [courseId, [] as CourseHierarchyRow[]] as const
+ }
+ }),
+ )
+ const nextRowsByCourseId = Object.fromEntries(courseEntries)
+ setCourseRowsByCourseId(nextRowsByCourseId)
+
+ const levelIds = Array.from(
+ new Set(
+ courseEntries
+ .flatMap((entry) => entry[1])
+ .map((row) => Number(row.level_id))
+ .filter((id) => Number.isFinite(id) && id > 0),
+ ),
+ )
+
+ const levelPracticeEntries = await Promise.all(
+ levelIds.map(async (levelId) => {
+ try {
+ const practiceRes = await getPracticesByLevel(levelId)
+ return [levelId, (practiceRes.data?.data?.practices ?? []).filter((practice) => practice.is_active)] as const
+ } catch {
+ return [levelId, [] as Practice[]] as const
+ }
+ }),
+ )
+ setLevelPracticesByLevelId(Object.fromEntries(levelPracticeEntries))
+ } catch (err) {
+ console.error(err)
+ setCourseRowsByCourseId({})
+ setLevelPracticesByLevelId({})
+ setCourseHierarchyError("Could not load hierarchy for selected courses")
+ toast.error("Failed to load course hierarchy")
+ } finally {
+ setCourseHierarchyLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ void fetchHumanLanguageHierarchy()
+ }, [fetchHumanLanguageHierarchy])
+
+ useEffect(() => {
+ window.sessionStorage.removeItem(HUMAN_LANGUAGE_RETURN_STATE_KEY)
+ }, [])
+
+ useEffect(() => {
+ if (!hierarchyLoading) {
+ skipFilterValidationRef.current = false
+ }
+ }, [hierarchyLoading])
+
+ useEffect(() => {
+ if (skipInitialSubCategoryResetRef.current) {
+ skipInitialSubCategoryResetRef.current = false
+ return
+ }
+ if (selectedSubCategoryId === "ALL") {
+ setSelectedCourseId("ALL")
+ setSelectedLevelId("ALL")
+ setCourseRowsByCourseId({})
+ setLevelPracticesByLevelId({})
+ setExpandedCourses(new Set())
+ setExpandedLevels(new Set())
+ setExpandedModules(new Set())
+ return
+ }
+ setSelectedCourseId("ALL")
+ setSelectedLevelId("ALL")
+ }, [selectedSubCategoryId])
+
+ useEffect(() => {
+ if (selectedSubCategoryId === "ALL") return
+ void fetchHierarchiesForCourses(targetCourseIds)
+ }, [selectedSubCategoryId, targetCourseIds, fetchHierarchiesForCourses])
+
+ useEffect(() => {
+ if (skipFilterValidationRef.current) return
+ if (selectedSubCategoryId !== "ALL" && !subCategoryOptions.some((item) => item.id === selectedSubCategoryId)) {
+ setSelectedSubCategoryId("ALL")
+ }
+ }, [selectedSubCategoryId, subCategoryOptions])
+
+ useEffect(() => {
+ if (skipFilterValidationRef.current) return
+ if (selectedCourseId !== "ALL" && !courseOptions.some((item) => item.id === selectedCourseId)) {
+ setSelectedCourseId("ALL")
+ }
+ }, [selectedCourseId, courseOptions])
+
+ useEffect(() => {
+ if (skipFilterValidationRef.current) return
+ const levelIds = new Set(courseTrees.flatMap((course) => course.levels.map((level) => level.id)))
+ if (selectedLevelId !== "ALL" && !levelIds.has(selectedLevelId)) {
+ setSelectedLevelId("ALL")
+ }
+ }, [selectedLevelId, courseTrees])
+
+ useEffect(() => {
+ const courseKeys = new Set(courseTrees.map((course) => `course-${course.course_id}`))
+ const levelKeys = new Set(
+ courseTrees.flatMap((course) => course.levels.map((level) => `level-${course.course_id}-${level.id}`)),
+ )
+ const moduleKeys = new Set(
+ courseTrees.flatMap((course) =>
+ course.levels.flatMap((level) => level.modules.map((module) => `module-${course.course_id}-${level.id}-${module.id}`)),
+ ),
+ )
+
+ setExpandedCourses((previous) => new Set([...previous].filter((key) => courseKeys.has(key))))
+ setExpandedLevels((previous) => new Set([...previous].filter((key) => levelKeys.has(key))))
+ setExpandedModules((previous) => new Set([...previous].filter((key) => moduleKeys.has(key))))
+
+ if (courseTrees.length > 0) {
+ const firstCourseKey = `course-${courseTrees[0].course_id}`
+ setExpandedCourses((previous) => (previous.size > 0 ? previous : new Set([firstCourseKey])))
+ }
+ }, [courseTrees])
+
+ useEffect(() => {
+ if (pendingRestoreScrollRef.current == null) return
+ if (courseHierarchyLoading) return
+ const targetY = pendingRestoreScrollRef.current
+ pendingRestoreScrollRef.current = null
+ window.setTimeout(() => {
+ window.scrollTo({ top: targetY, behavior: "smooth" })
+ }, 60)
+ }, [courseHierarchyLoading, courseTrees])
+
+ const handleActionClick = (label: string) => {
+ toast.info(`${label} UI is ready. Endpoint wiring can be added next.`)
+ }
+
+ const openCreateModuleModal = (courseId: number, level: LevelNode, levelKey: string) => {
+ setCreateModuleCourseId(courseId)
+ setCreateModuleLevelId(level.id)
+ setCreateModuleLevelKey(levelKey)
+ setCreateModuleUseDefaultNaming(false)
+ setCreateModuleDefaultTitle(getNextDefaultModuleName(level))
+ setCreateModuleTitle("")
+ setCreateModuleDescription("")
+ setCreateModuleIconSource("url")
+ setCreateModuleIconUrl("")
+ setCreateModuleIconFile(null)
+ setCreateModuleDisplayOrder(level.modules.length)
+ setCreateModuleOpen(true)
+ }
+
+ const handleCreateModule = async () => {
+ if (createModuleLevelId == null || createModuleCourseId == null) return
+ const title = (createModuleUseDefaultNaming ? createModuleDefaultTitle : createModuleTitle).trim()
+ if (!title) {
+ toast.error("Module title is required")
+ return
+ }
+
+ setCreateModuleSaving(true)
+ try {
+ let uploadedIconUrl: string | undefined
+ if (createModuleIconSource === "url" && createModuleIconUrl.trim()) {
+ const uploadRes = await uploadImageFile(createModuleIconUrl.trim())
+ uploadedIconUrl = uploadRes.data?.data?.url?.trim() || undefined
+ if (!uploadedIconUrl) {
+ throw new Error("Icon upload from URL did not return a file URL")
+ }
+ } else if (createModuleIconSource === "file" && createModuleIconFile) {
+ const uploadRes = await uploadImageFile(createModuleIconFile)
+ uploadedIconUrl = uploadRes.data?.data?.url?.trim() || undefined
+ if (!uploadedIconUrl) {
+ throw new Error("Icon file upload did not return a file URL")
+ }
+ }
+
+ await createModule({
+ level_id: createModuleLevelId,
+ title,
+ description: createModuleDescription.trim() || undefined,
+ icon_url: uploadedIconUrl,
+ display_order: createModuleDisplayOrder,
+ is_active: true,
+ })
+ toast.success("Module created")
+
+ setExpandedCourses((previous) => new Set(previous).add(`course-${createModuleCourseId}`))
+ setExpandedLevels((previous) => new Set(previous).add(createModuleLevelKey))
+ setCreateModuleOpen(false)
+
+ const refreshCourseIds =
+ selectedCourseId === "ALL" ? targetCourseIds : [createModuleCourseId]
+ await fetchHierarchiesForCourses(refreshCourseIds)
+ } catch (error) {
+ console.error(error)
+ toast.error("Failed to create module")
+ } finally {
+ setCreateModuleSaving(false)
+ }
+ }
+
+ const openDeleteModuleDialog = (courseId: number, levelTitle: string, module: ModuleNode, moduleKey: string) => {
+ if (deleteModuleSavingId != null) return
+ setDeleteModuleTarget({
+ courseId,
+ moduleId: module.id,
+ moduleTitle: module.title,
+ levelTitle,
+ moduleKey,
+ })
+ setDeleteModuleDialogOpen(true)
+ }
+
+ const openEditModuleDialog = (
+ courseId: number,
+ levelKey: string,
+ moduleKey: string,
+ module: ModuleNode,
+ moduleDisplayOrder: number,
+ ) => {
+ if (editModuleSaving) return
+ const existingIconUrl = module.icon_url?.trim() ?? ""
+ setEditModuleTarget({
+ courseId,
+ moduleId: module.id,
+ moduleKey,
+ levelKey,
+ })
+ setEditModuleTitle(module.title)
+ setEditModuleDescription("")
+ setEditModuleDisplayOrder(moduleDisplayOrder)
+ setEditModuleIconSource("url")
+ setEditModuleIconUrl(existingIconUrl)
+ setEditModuleOriginalIconUrl(existingIconUrl)
+ setEditModuleIconFile(null)
+ setEditModuleDialogOpen(true)
+ }
+
+ const handleUpdateModule = async () => {
+ if (!editModuleTarget) return
+ const title = editModuleTitle.trim()
+ if (!title) {
+ toast.error("Module title is required")
+ return
+ }
+
+ setEditModuleSaving(true)
+ try {
+ const inputIconUrl = editModuleIconUrl.trim()
+ let uploadedIconUrl: string | undefined
+ if (editModuleIconSource === "file" && editModuleIconFile) {
+ const uploadRes = await uploadImageFile(editModuleIconFile)
+ uploadedIconUrl = uploadRes.data?.data?.url?.trim() || undefined
+ if (!uploadedIconUrl) {
+ throw new Error("Icon file upload did not return a file URL")
+ }
+ } else if (editModuleIconSource === "url") {
+ if (!inputIconUrl) uploadedIconUrl = undefined
+ else if (inputIconUrl === editModuleOriginalIconUrl) uploadedIconUrl = inputIconUrl
+ else {
+ const uploadRes = await uploadImageFile(inputIconUrl)
+ uploadedIconUrl = uploadRes.data?.data?.url?.trim() || undefined
+ if (!uploadedIconUrl) {
+ throw new Error("Icon upload from URL did not return a file URL")
+ }
+ }
+ }
+
+ await updateModule(editModuleTarget.moduleId, {
+ title,
+ description: editModuleDescription.trim() || undefined,
+ icon_url: uploadedIconUrl,
+ display_order: editModuleDisplayOrder,
+ is_active: true,
+ })
+ toast.success("Module updated")
+
+ setExpandedCourses((previous) => new Set(previous).add(`course-${editModuleTarget.courseId}`))
+ setExpandedLevels((previous) => new Set(previous).add(editModuleTarget.levelKey))
+ setExpandedModules((previous) => new Set(previous).add(editModuleTarget.moduleKey))
+ setEditModuleDialogOpen(false)
+ setEditModuleTarget(null)
+
+ const refreshCourseIds = selectedCourseId === "ALL" ? targetCourseIds : [editModuleTarget.courseId]
+ await fetchHierarchiesForCourses(refreshCourseIds)
+ } catch (error) {
+ console.error(error)
+ toast.error("Failed to update module")
+ } finally {
+ setEditModuleSaving(false)
+ }
+ }
+
+ const handleDeleteModule = async () => {
+ if (deleteModuleSavingId != null) return
+ if (!deleteModuleTarget) return
+
+ setDeleteModuleSavingId(deleteModuleTarget.moduleId)
+ try {
+ await deleteModule(deleteModuleTarget.moduleId)
+ toast.success("Module deleted")
+ setExpandedModules((previous) => {
+ const next = new Set(previous)
+ next.delete(deleteModuleTarget.moduleKey)
+ return next
+ })
+
+ setDeleteModuleDialogOpen(false)
+ setDeleteModuleTarget(null)
+
+ const refreshCourseIds = selectedCourseId === "ALL" ? targetCourseIds : [deleteModuleTarget.courseId]
+ await fetchHierarchiesForCourses(refreshCourseIds)
+ } catch (error) {
+ console.error(error)
+ toast.error("Failed to delete module")
+ } finally {
+ setDeleteModuleSavingId(null)
+ }
+ }
+
+ const persistReturnState = () => {
+ const payload: HierarchyReturnState = {
+ selectedSubCategoryId,
+ selectedCourseId,
+ selectedLevelId,
+ expandedCourses: Array.from(expandedCourses),
+ expandedLevels: Array.from(expandedLevels),
+ expandedModules: Array.from(expandedModules),
+ scrollY: window.scrollY,
+ }
+ window.sessionStorage.setItem(HUMAN_LANGUAGE_RETURN_STATE_KEY, JSON.stringify(payload))
+ }
+
+ return (
+
+
+
+
+
+
+
+
Human Language
+
Hierarchy management
+
+
+
+ Back to Content Management
+
+
+
+
+
+ Filters
+
+
+
+
+
+ Subcategory
+
+ {hierarchyLoading ? (
+
+
+ Loading hierarchy...
+
+ ) : hierarchyError ? (
+
+
{hierarchyError}
+
void fetchHumanLanguageHierarchy()}>
+ Retry
+
+
+ ) : (
+
{
+ const value = event.target.value
+ setSelectedSubCategoryId(value === "ALL" ? "ALL" : Number(value))
+ }}
+ >
+ All subcategories
+ {subCategoryOptions.map((subCategory) => (
+
+ {subCategory.name}
+
+ ))}
+
+ )}
+
+
+
+
+ Course
+
+ {selectedSubCategoryId === "ALL" ? (
+
+ Select a sub-category first.
+
+ ) : (
+
{
+ const value = event.target.value
+ setSelectedCourseId(value === "ALL" ? "ALL" : Number(value))
+ }}
+ >
+ All courses
+ {courseOptions.map((course) => (
+
+ {course.title}
+
+ ))}
+
+ )}
+
+
+
+
+ Fetch lessons by level
+
+ {selectedCourseId === "ALL" ? (
+
+ ALL LEVELS
+
+ ) : (
+ {
+ const value = event.target.value
+ setSelectedLevelId(value === "ALL" ? "ALL" : Number(value))
+ }}
+ >
+ ALL LEVELS
+ {courseTrees
+ .flatMap((course) => course.levels)
+ .map((level) => (
+
+ {level.title}
+
+ ))}
+
+ )}
+
+
+
+
+
+ {selectedSubCategoryId !== "ALL" ? (
+
+ handleActionClick("Delete selected sub-category")}>
+
+ Delete selected sub-category
+
+ handleActionClick("Delete selected course")}>
+
+ Delete selected course
+
+
+ ) : null}
+
+ {selectedSubCategoryId === "ALL" ? (
+
+
+
+
+
Select a sub-category to start managing hierarchy
+
+ Powered by `GET /course-management/human-language/hierarchy` and `GET /course-management/courses/:courseId/hierarchy`.
+
+
+
+
+ ) : courseHierarchyLoading ? (
+
+
+ Loading hierarchy tree...
+
+ ) : courseHierarchyError ? (
+
+
+ {courseHierarchyError}
+ void fetchHierarchiesForCourses(targetCourseIds)}>
+ Retry
+
+
+
+ ) : courseTrees.length === 0 ? (
+
+ No hierarchy records found for this selection.
+
+ ) : (
+
+ {courseTrees.map((course) => {
+ const courseKey = `course-${course.course_id}`
+ const courseOpen = setHas(expandedCourses, courseKey)
+ return (
+
+
+
+ toggleSetValue(setExpandedCourses, courseKey)}
+ >
+ {courseOpen ? : }
+ {course.course_title}
+
+ {course.levels.length} levels
+
+
+ handleActionClick("Add next CEFR level")}>
+ Add next CEFR level
+
+
+
+ {courseOpen ? (
+
+ {course.levels.length === 0 ? (
+
No levels for this course.
+ ) : (
+ course.levels.map((level) => {
+ const levelKey = `level-${course.course_id}-${level.id}`
+ const levelOpen = setHas(expandedLevels, levelKey)
+ const levelPractices = levelPracticesByLevelId[level.id] ?? []
+ return (
+
+
+ toggleSetValue(setExpandedLevels, levelKey)}
+ >
+ {levelOpen ? (
+
+ ) : (
+
+ )}
+ {level.title}
+
+ {level.modules.length} module(s)
+
+
+ handleActionClick("Remove level")}>
+
+ Remove
+
+
+
+ {levelOpen ? (
+
+
+
openCreateModuleModal(course.course_id, level, levelKey)}
+ >
+
+ Add Module
+
+
+
+ {levelPractices.length > 0 ? (
+
+
Level practices
+
+ {levelPractices.map((practice) => (
+ {practice.title}
+ ))}
+
+
+ ) : null}
+
+ {level.modules.length === 0 ? (
+
No modules yet.
+ ) : (
+
+ {level.modules.map((module) => {
+ const moduleKey = `module-${course.course_id}-${level.id}-${module.id}`
+ const moduleOpen = setHas(expandedModules, moduleKey)
+ return (
+
+
+
toggleSetValue(setExpandedModules, moduleKey)}
+ >
+ {moduleOpen ? (
+
+ ) : (
+
+ )}
+ {module.icon_url ? (
+
+ ) : null}
+ Module: {module.title}
+
+ {module.sub_modules.length} sub-module(s)
+
+
+
+
handleActionClick("Add sub-module")}>
+
+ Add Sub-module
+
+
+ openEditModuleDialog(course.course_id, levelKey, moduleKey, module, level.modules.indexOf(module) + 1)
+ }
+ >
+
+ Edit
+
+
openDeleteModuleDialog(course.course_id, level.title, module, moduleKey)}
+ >
+
+ {deleteModuleSavingId === module.id ? "Removing..." : "Remove"}
+
+
+
+
+ {moduleOpen ? (
+
+ {module.sub_modules.length === 0 ? (
+
No sub-modules yet.
+ ) : (
+ module.sub_modules.map((subModule) => (
+
+
+ Sub-module: {subModule.title}
+
+
+
+
+ Open
+
+
+ handleActionClick("Remove sub-module")}
+ >
+
+ Remove
+
+
+
+ ))
+ )}
+
+ ) : null}
+
+ )
+ })}
+
+ )}
+
+ ) : null}
+
+ )
+ })
+ )}
+
+ ) : null}
+
+
+ )
+ })}
+
+ )}
+
+
(!createModuleSaving ? setCreateModuleOpen(open) : null)}>
+
+
+ Create module
+
+ Add a module to this level. This will call `POST /course-management/modules`.
+
+
+
+
+
+
Module naming
+
+ setCreateModuleUseDefaultNaming(false)}
+ >
+ Custom title
+
+ setCreateModuleUseDefaultNaming(true)}
+ >
+ Default naming
+
+
+ {createModuleUseDefaultNaming ? (
+
Auto title: {createModuleDefaultTitle}
+ ) : null}
+
+
+
+ Title
+ setCreateModuleTitle(event.target.value)}
+ placeholder="e.g. Introduction"
+ disabled={createModuleSaving || createModuleUseDefaultNaming}
+ />
+
+
+
+ Description (optional)
+
+
+
+
Icon URL (optional)
+
+ setCreateModuleIconSource("url")}
+ >
+ Public URL
+
+ setCreateModuleIconSource("file")}
+ >
+ Upload from PC
+
+
+ {createModuleIconSource === "url" ? (
+
setCreateModuleIconUrl(event.target.value)}
+ placeholder="https://example.com/icon.png"
+ disabled={createModuleSaving}
+ />
+ ) : (
+
{
+ const file = event.target.files?.[0] ?? null
+ setCreateModuleIconFile(file)
+ }}
+ />
+ )}
+
+ Icon is uploaded through `/files/upload` and the returned MinIO URL is saved as `icon_url`.
+
+
+
+
+
+ setCreateModuleOpen(false)}
+ >
+ Cancel
+
+ void handleCreateModule()}
+ >
+ {createModuleSaving ? "Creating..." : "Create module"}
+
+
+
+
+
+
{
+ if (editModuleSaving) return
+ setEditModuleDialogOpen(open)
+ if (!open) setEditModuleTarget(null)
+ }}
+ >
+
+
+ Update module
+
+ Update this module using `PUT /course-management/modules/:moduleId`.
+
+
+
+
+
+ Title
+ setEditModuleTitle(event.target.value)}
+ placeholder="Updated title"
+ disabled={editModuleSaving}
+ />
+
+
+
+ Description
+
+
+
+ Display order
+ setEditModuleDisplayOrder(Math.max(0, Number(event.target.value) || 0))}
+ disabled={editModuleSaving}
+ />
+
+
+
+
Icon URL (optional)
+
+ setEditModuleIconSource("url")}
+ >
+ Public URL
+
+ setEditModuleIconSource("file")}
+ >
+ Upload from PC
+
+
+ {editModuleIconSource === "url" ? (
+
setEditModuleIconUrl(event.target.value)}
+ placeholder="https://example.com/icon.png"
+ disabled={editModuleSaving}
+ />
+ ) : (
+
{
+ const file = event.target.files?.[0] ?? null
+ setEditModuleIconFile(file)
+ }}
+ />
+ )}
+
+ If you provide a new icon, it is uploaded through `/files/upload`, then saved as `icon_url`.
+
+
+
+
+
+ {
+ setEditModuleDialogOpen(false)
+ setEditModuleTarget(null)
+ }}
+ >
+ Cancel
+
+ void handleUpdateModule()}
+ >
+ {editModuleSaving ? "Updating..." : "Update module"}
+
+
+
+
+
+
{
+ if (deleteModuleSavingId != null) return
+ setDeleteModuleDialogOpen(open)
+ if (!open) setDeleteModuleTarget(null)
+ }}
+ >
+
+
+ Delete module?
+
+ This will permanently remove{" "}
+ {deleteModuleTarget?.moduleTitle ?? "this module"} from{" "}
+ {deleteModuleTarget?.levelTitle ?? "this level"} .
+
+
+
+ {
+ setDeleteModuleDialogOpen(false)
+ setDeleteModuleTarget(null)
+ }}
+ >
+ Cancel
+
+ void handleDeleteModule()}
+ >
+ {deleteModuleSavingId != null ? "Deleting..." : "Delete module"}
+
+
+
+
+
+ )
+}
diff --git a/src/pages/content-management/HumanLanguagePage.tsx b/src/pages/content-management/HumanLanguagePage.tsx
deleted file mode 100644
index d6e2ec9..0000000
--- a/src/pages/content-management/HumanLanguagePage.tsx
+++ /dev/null
@@ -1,2262 +0,0 @@
-import { useEffect, useMemo, useState, type ChangeEvent } from "react"
-import { Link, useNavigate } from "react-router-dom"
-import {
- ChevronDown,
- ChevronRight,
- ClipboardList,
- GripVertical,
- HelpCircle,
- Image as ImageIcon,
- Languages,
- Lightbulb,
- Link2,
- Loader2,
- Mic,
- Plus,
- Search,
- Trash2,
- Video,
-} from "lucide-react"
-import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
-import { Button } from "../../components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "../../components/ui/dialog"
-import { SpinnerIcon } from "../../components/ui/spinner-icon"
-import {
- addQuestionToSet,
- createPractice,
- createQuestion,
- createCourse,
- createCourseCategory,
- createHumanLanguageLesson,
- deleteQuestionSet,
- deleteQuestion,
- deleteSubModule,
- getHumanLanguageHierarchy,
- getQuestionById,
- getPracticeQuestions,
- getPracticeQuestionsByPractice,
- getQuestionSetById,
- updateQuestionSet,
- updateQuestion,
-} from "../../api/courses.api"
-import { Badge } from "../../components/ui/badge"
-import type {
- CreateQuestionRequest,
- HumanLanguageCourseTree,
- HumanLanguageSubCategoryTree,
- LearningPathPractice,
- LearningPathVideo,
- QuestionDetail,
- QuestionSetQuestion,
-} from "../../types/course.types"
-import { cn } from "../../lib/utils"
-import { toast } from "sonner"
-import { Input } from "../../components/ui/input"
-import { uploadVideoFile } from "../../api/files.api"
-import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
-import {
- createEmptyPracticeQuestionDraft,
- PracticeQuestionEditorFields,
- type PracticeQuestionEditorValue,
-} from "../../components/content-management/PracticeQuestionEditorFields"
-
-const CEFR_LEVELS = ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3"] as const
-type SubModulePanelTab = "lessons" | "practices"
-
-type SubModuleCardSelection = { lessonId: number | null; practiceId: number | null }
-
-type PracticeQuestionsFetchState =
- | { status: "idle" }
- | { status: "loading"; startedAt: number }
- | { status: "ok"; questions: QuestionSetQuestion[]; totalCount: number }
- | { status: "error"; message: string }
-
-type PracticeDialogState =
- | { open: false }
- | {
- open: true
- mode: "create" | "edit"
- subModuleId: number
- practiceId?: number
- }
-
-type QuestionDialogState =
- | { open: false }
- | {
- open: true
- mode: "create" | "edit"
- practiceId: number
- questionId?: number
- }
-
-function formatDurationSeconds(total: number): string {
- const s = Math.max(0, Math.floor(total))
- const m = Math.floor(s / 60)
- const r = s % 60
- return `${m}:${r.toString().padStart(2, "0")}`
-}
-
-function practiceStatusStyle(status: string): string {
- const u = status.toUpperCase()
- if (u === "PUBLISHED") return "bg-green-50 text-green-700 ring-1 ring-inset ring-green-200"
- if (u === "DRAFT") return "bg-grayScale-50 text-grayScale-600 ring-1 ring-inset ring-grayScale-200"
- if (u === "ARCHIVED") return "bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200"
- return "bg-grayScale-50 text-grayScale-600 ring-1 ring-inset ring-grayScale-200"
-}
-
-function questionTypeBadgeClass(questionType: string): string {
- const t = questionType.toUpperCase().replace(/\s+/g, "_")
- if (t === "MCQ" || t.includes("MULTIPLE")) {
- return "border-transparent bg-violet-50 text-violet-800 ring-1 ring-inset ring-violet-200"
- }
- if (t === "TRUE_FALSE" || t.includes("TRUE")) {
- return "border-transparent bg-sky-50 text-sky-800 ring-1 ring-inset ring-sky-200"
- }
- if (t === "SHORT" || t === "SHORT_ANSWER") {
- return "border-transparent bg-emerald-50 text-emerald-800 ring-1 ring-inset ring-emerald-200"
- }
- if (t === "AUDIO") {
- return "border-transparent bg-orange-50 text-orange-800 ring-1 ring-inset ring-orange-200"
- }
- return "border-transparent bg-grayScale-100 text-grayScale-700 ring-1 ring-inset ring-grayScale-200"
-}
-
-function formatQuestionTypeLabel(raw: string): string {
- return String(raw ?? "—")
- .replace(/_/g, " ")
- .trim()
- .toLowerCase()
- .replace(/\b\w/g, (c) => c.toUpperCase())
-}
-
-const URL_REGEX = /(https?:\/\/[^\s<>"')\]]+)/gi
-
-function extractUrls(text: string): string[] {
- const out = text.match(URL_REGEX) ?? []
- return [...new Set(out)]
-}
-
-function normalizeUrl(raw: string): string {
- return raw.trim().replace(/[),.;!?]+$/, "")
-}
-
-function getVimeoEmbedUrl(url: string): string | null {
- const m = url.match(/vimeo\.com\/(?:video\/)?(\d+)/i)
- return m?.[1] ? `https://player.vimeo.com/video/${m[1]}` : null
-}
-
-function detectMediaType(url: string, hint?: "audio" | "video" | "image"): "audio" | "video" | "image" | "unknown" {
- if (hint) return hint
- const vimeo = getVimeoEmbedUrl(url)
- if (vimeo) return "video"
- const clean = url.split("?")[0].toLowerCase()
- if (/\.(png|jpe?g|gif|webp|svg|avif|bmp)$/.test(clean)) return "image"
- if (/\.(mp4|webm|ogg|mov|m4v)$/.test(clean)) return "video"
- if (/\.(mp3|wav|m4a|aac|ogg|webm)$/.test(clean)) return "audio"
- return "unknown"
-}
-
-function withTimeout(promise: Promise, ms: number): Promise {
- return new Promise((resolve, reject) => {
- const timer = setTimeout(() => reject(new Error("Request timed out")), ms)
- promise
- .then((value) => {
- clearTimeout(timer)
- resolve(value)
- })
- .catch((err) => {
- clearTimeout(timer)
- reject(err)
- })
- })
-}
-type CefrLevel = (typeof CEFR_LEVELS)[number]
-
-type PendingRemove = {
- ids: number[]
- key: string
- successMessage: string
- title: string
- description: string
-}
-
-function MediaPreviewCard({
- urlRaw,
- hint,
- className = "mt-2",
- label,
-}: {
- urlRaw: string
- hint?: "audio" | "video" | "image"
- className?: string
- label?: string
-}) {
- const normalized = normalizeUrl(urlRaw)
- const [resolvedUrl, setResolvedUrl] = useState(normalized)
- const [resolving, setResolving] = useState(false)
-
- useEffect(() => {
- let cancelled = false
- const run = async () => {
- if (!normalized) {
- setResolvedUrl("")
- return
- }
- if (/^https?:\/\//i.test(normalized)) {
- setResolvedUrl(normalized)
- return
- }
- setResolving(true)
- try {
- const url = await resolveMediaPreviewUrl(normalized)
- if (!cancelled) setResolvedUrl(url || normalized)
- } catch {
- if (!cancelled) setResolvedUrl(normalized)
- } finally {
- if (!cancelled) setResolving(false)
- }
- }
- void run()
- return () => {
- cancelled = true
- }
- }, [normalized])
-
- if (!normalized) return null
- const previewUrl = resolvedUrl || normalized
- const mediaType = detectMediaType(previewUrl, hint)
- const vimeoEmbed = getVimeoEmbedUrl(previewUrl)
- const showPlayer = mediaType === "image" || mediaType === "video" || mediaType === "audio"
-
- return (
-
- {label ? (
-
- {hint === "image" ? (
-
- ) : hint === "audio" ? (
-
- ) : hint === "video" ? (
-
- ) : (
-
- )}
- {label}
-
- ) : null}
- {resolving ? (
-
-
- Resolving media URL...
-
- ) : mediaType === "image" ? (
-
- ) : mediaType === "video" ? (
- vimeoEmbed ? (
-
- ) : (
-
- )
- ) : mediaType === "audio" ? (
-
- ) : (
-
Preview not available for this URL type.
- )}
-
-
- Open link
-
-
- )
-}
-
-function nextMissingPositive(values: number[]): number {
- const existing = new Set(values.filter((n) => Number.isFinite(n) && n > 0))
- let candidate = 1
- while (existing.has(candidate)) candidate += 1
- return candidate
-}
-
-export function HumanLanguagePage() {
- const navigate = useNavigate()
- const [loading, setLoading] = useState(false)
- const [categoryId, setCategoryId] = useState(null)
- const [subCategories, setSubCategories] = useState([])
- const [selectedSubCategoryId, setSelectedSubCategoryId] = useState("ALL")
- const [selectedCourseId, setSelectedCourseId] = useState("ALL")
- const [selectedLevel, setSelectedLevel] = useState("ALL")
- const [collapsedLevels, setCollapsedLevels] = useState([])
- const [collapsedModuleIds, setCollapsedModuleIds] = useState([])
- const [collapsedSubModuleIds, setCollapsedSubModuleIds] = useState([])
- const [creatingKey, setCreatingKey] = useState(null)
- const [quickSubCategoryName, setQuickSubCategoryName] = useState("")
- const [quickCourseName, setQuickCourseName] = useState("")
- const [quickSearch, setQuickSearch] = useState("")
- const [quickCreating, setQuickCreating] = useState(false)
- const [deletingKey, setDeletingKey] = useState(null)
- /** Course IDs whose path body is collapsed (headers stay visible). */
- const [collapsedPathIds, setCollapsedPathIds] = useState([])
- const [pendingRemove, setPendingRemove] = useState(null)
- /** Per sub-module panel tab (lessons vs practices). */
- const [subModulePanelTab, setSubModulePanelTab] = useState>({})
- /** Selected lesson / practice card per sub-module (for inline detail panel). */
- const [subModuleCardSelection, setSubModuleCardSelection] = useState>({})
- const [practiceQuestionsState, setPracticeQuestionsState] = useState>({})
- const [practiceDialog, setPracticeDialog] = useState({ open: false })
- const [questionDialog, setQuestionDialog] = useState({ open: false })
- const [practiceForm, setPracticeForm] = useState({
- title: "",
- description: "",
- persona: "",
- introVideoUrl: "",
- passingScore: 50,
- timeLimitMinutes: 60,
- shuffleQuestions: false,
- })
- const [questionDraft, setQuestionDraft] = useState(() => createEmptyPracticeQuestionDraft())
- const [questionDetailById, setQuestionDetailById] = useState>({})
- 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 [savingQuestion, setSavingQuestion] = useState(false)
- const [deletingPractice, setDeletingPractice] = useState(false)
- const [deletingQuestion, setDeletingQuestion] = useState(false)
- /** While fetching full question detail before opening the edit dialog (avoids empty form flash). */
- 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 [questionSubmitAttempted, setQuestionSubmitAttempted] = useState(false)
- const [practiceFormTouched, setPracticeFormTouched] = useState(false)
- const [questionFormTouched, setQuestionFormTouched] = useState(false)
- const [loadingPracticeForm, setLoadingPracticeForm] = useState(false)
- const [uploadingPracticeIntroVideo, setUploadingPracticeIntroVideo] = useState(false)
-
- const renderMediaPreview = (
- urlRaw: string,
- hint?: "audio" | "video" | "image",
- className = "mt-2",
- label?: string,
- ) =>
-
- const loadHierarchy = async () => {
- setLoading(true)
- try {
- const res = await getHumanLanguageHierarchy()
- const data = res.data?.data
- setCategoryId(data?.category_id ?? null)
- const nextSubCategories = data?.sub_categories ?? []
- setSubCategories(nextSubCategories)
- // Default UI behavior: modules and sub-modules start collapsed.
- const moduleIds = nextSubCategories.flatMap((subCategory) =>
- subCategory.courses.flatMap((course) =>
- course.levels.flatMap((levelNode) => levelNode.modules.map((module) => module.id)),
- ),
- )
- const subModuleIds = nextSubCategories.flatMap((subCategory) =>
- subCategory.courses.flatMap((course) =>
- course.levels.flatMap((levelNode) =>
- levelNode.modules.flatMap((module) => module.sub_modules.map((subModule) => subModule.id)),
- ),
- ),
- )
- setCollapsedModuleIds(moduleIds)
- setCollapsedSubModuleIds(subModuleIds)
- } finally {
- setLoading(false)
- }
- }
-
- useEffect(() => {
- const run = async () => {
- setLoading(true)
- try {
- await loadHierarchy()
- } finally {
- setLoading(false)
- }
- }
- run().catch(() => undefined)
- }, [])
-
- const filteredSubCategories = useMemo(
- () =>
- selectedSubCategoryId === "ALL"
- ? subCategories
- : subCategories.filter((s) => s.sub_category_id === selectedSubCategoryId),
- [subCategories, selectedSubCategoryId],
- )
-
- 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],
- )
-
- /** A1 always; A2–C3 only after that level has at least one module (incremental UI). */
- const visibleCefrLevels = useMemo(() => {
- if (availableCourses.length === 0) return [] as CefrLevel[]
- const out: CefrLevel[] = []
- for (const level of CEFR_LEVELS) {
- if (level === "A1") {
- out.push(level)
- continue
- }
- const hasContent = selectedCourses.some((c) => {
- const node = c.levels.find((item) => item.level.toUpperCase() === level)
- return node !== undefined && (node.modules?.length ?? 0) > 0
- })
- if (hasContent) out.push(level)
- }
- return out
- }, [availableCourses.length, selectedCourses])
-
- useEffect(() => {
- if (selectedLevel === "ALL") return
- if (!visibleCefrLevels.includes(selectedLevel)) {
- setSelectedLevel("ALL")
- }
- }, [selectedLevel, visibleCefrLevels])
-
- const toggleLevel = (levelKey: string) => {
- setCollapsedLevels((prev) => (prev.includes(levelKey) ? prev.filter((l) => l !== levelKey) : [...prev, levelKey]))
- }
-
- const togglePathCollapsed = (courseId: number) => {
- setCollapsedPathIds((prev) =>
- prev.includes(courseId) ? prev.filter((id) => id !== courseId) : [...prev, courseId],
- )
- }
-
- const toggleModuleCollapsed = (moduleId: number) => {
- setCollapsedModuleIds((prev) =>
- prev.includes(moduleId) ? prev.filter((id) => id !== moduleId) : [...prev, moduleId],
- )
- }
-
- const toggleSubModuleCollapsed = (subModuleId: number) => {
- setCollapsedSubModuleIds((prev) =>
- prev.includes(subModuleId) ? prev.filter((id) => id !== subModuleId) : [...prev, subModuleId],
- )
- }
-
- const levelsWithContentForCourse = (course: HumanLanguageCourseTree) =>
- course.levels.filter((l) => (l.modules?.length ?? 0) > 0).map((l) => l.level.toUpperCase())
-
- 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 usedNumbers = modules
- .map((m) => parseModuleNumber(m.title))
- .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}`,
- })
- 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 usedNumbers = existingSubModules
- .map((s) => parseSubModuleNumber(s.title))
- .filter((v): v is { module: number; sub: number } => v !== null && v.module === moduleNo)
- .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}`,
- })
- 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)
- }
- }
-
- const requestRemove = (payload: PendingRemove) => {
- if (payload.ids.length === 0) return
- setPendingRemove(payload)
- }
-
- const executePendingRemove = async () => {
- if (!pendingRemove) return
- const { ids, key, successMessage } = pendingRemove
- setPendingRemove(null)
- setDeletingKey(key)
- try {
- for (const id of ids) {
- await deleteSubModule(id)
- }
- toast.success(successMessage)
- await loadHierarchy()
- } catch (error) {
- console.error("Failed to delete item(s):", error)
- toast.error("Failed to delete item(s)")
- } finally {
- setDeletingKey(null)
- }
- }
-
- const handleCreateNextLevelForCourse = async (courseId: number) => {
- const course = availableCourses.find((c) => c.course_id === courseId)
- if (!course) {
- toast.error("Course not found")
- return
- }
- const existing = new Set(levelsWithContentForCourse(course))
- const next = CEFR_LEVELS.find((level) => !existing.has(level))
- if (!next) {
- toast.error("All CEFR levels (A1–C3) already have content for this path")
- return
- }
- const key = `next-level-${courseId}-${next}`
- setCreatingKey(key)
- try {
- await createHumanLanguageLesson({
- course_id: courseId,
- cefr_level: next,
- title: "Module-1",
- description: `${next} Module-1`,
- })
- toast.success(`${next} created with Module-1`)
- await loadHierarchy()
- } catch (error) {
- console.error("Failed to create next level:", error)
- toast.error("Failed to create next level")
- } finally {
- setCreatingKey(null)
- }
- }
-
- const handleQuickCreatePath = async () => {
- if (!quickSubCategoryName.trim() || !quickCourseName.trim()) {
- toast.error("Subcategory and course names are required")
- return
- }
- setQuickCreating(true)
- try {
- let effectiveCategoryId = categoryId
- if (!effectiveCategoryId) {
- const createdCategory = await createCourseCategory({ name: "Human Language" })
- effectiveCategoryId = createdCategory.data?.data?.id ?? null
- setCategoryId(effectiveCategoryId)
- }
- if (!effectiveCategoryId) {
- throw new Error("Missing human language category id")
- }
- const title = `${quickSubCategoryName.trim()} - ${quickCourseName.trim()}`
- await createCourse({
- category_id: effectiveCategoryId,
- title,
- description: `${quickSubCategoryName.trim()} / ${quickCourseName.trim()}`,
- })
- toast.success("Subcategory/course path created")
- setQuickSubCategoryName("")
- setQuickCourseName("")
- await loadHierarchy()
- } catch (error) {
- console.error("Failed to quick-create language path:", error)
- toast.error("Failed to create subcategory/course path")
- } finally {
- setQuickCreating(false)
- }
- }
-
- const loadPracticeQuestionsIfNeeded = async (practiceId: number, forceRefresh = false) => {
- let skipFetch = false
- setPracticeQuestionsState((prev) => {
- const ex = prev[practiceId]
- if (!forceRefresh && ex?.status === "ok") {
- skipFetch = true
- return prev
- }
- if (!forceRefresh && ex?.status === "loading" && Date.now() - ex.startedAt < 15000) {
- skipFetch = true
- return prev
- }
- return { ...prev, [practiceId]: { status: "loading", startedAt: Date.now() } }
- })
- if (skipFetch) return
- try {
- let questions: QuestionSetQuestion[] = []
- let totalCount = 0
- try {
- const res = await withTimeout(getPracticeQuestionsByPractice(practiceId, { limit: 200, offset: 0 }), 12000)
- const payload = res.data?.data
- questions = payload?.questions ?? []
- totalCount = payload?.total_count ?? questions.length
- } catch {
- // Fallback endpoint for environments where /practices/:id/questions can hang.
- const fallback = await withTimeout(getPracticeQuestions(practiceId), 12000)
- questions = fallback.data?.data ?? []
- totalCount = questions.length
- }
- setPracticeQuestionsState((prev) => ({
- ...prev,
- [practiceId]: { status: "ok", questions, totalCount },
- }))
- } catch (error) {
- console.error("Failed to load practice questions:", error)
- setPracticeQuestionsState((prev) => ({
- ...prev,
- [practiceId]: { status: "error", message: "Could not load questions" },
- }))
- }
- }
-
- const getSubModuleSelection = (smKey: string): SubModuleCardSelection =>
- subModuleCardSelection[smKey] ?? { lessonId: null, practiceId: null }
-
- const resetPracticeForm = () =>
- setPracticeForm({
- title: "",
- description: "",
- persona: "",
- introVideoUrl: "",
- passingScore: 50,
- timeLimitMinutes: 60,
- shuffleQuestions: false,
- })
- const resetQuestionForm = () => {
- setQuestionDraft(createEmptyPracticeQuestionDraft())
- }
-
- const openCreatePracticeDialog = (courseId: number, subModuleId: number) => {
- if (!categoryId) {
- toast.error("Category is not ready yet. Please try again.")
- return
- }
- navigate(`/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}/add-practice`)
- }
-
- const openEditPracticeDialog = async (subModuleId: number, p: LearningPathPractice) => {
- setPracticeSubmitAttempted(false)
- setPracticeFormTouched(false)
- setPracticeDialog({ open: true, mode: "edit", subModuleId, practiceId: p.id })
- setLoadingPracticeForm(true)
- try {
- const detail = (await getQuestionSetById(p.id)).data?.data
- setPracticeForm({
- title: detail?.title ?? p.title ?? "",
- description: detail?.description ?? "",
- persona: detail?.persona ?? "",
- introVideoUrl: detail?.intro_video_url ?? "",
- passingScore: detail?.passing_score ?? 50,
- timeLimitMinutes: detail?.time_limit_minutes ?? 60,
- shuffleQuestions: detail?.shuffle_questions ?? false,
- })
- } catch (error) {
- console.error("Failed to load practice detail:", error)
- setPracticeForm({
- title: p.title ?? "",
- description: "",
- persona: "",
- introVideoUrl: "",
- passingScore: 50,
- timeLimitMinutes: 60,
- shuffleQuestions: false,
- })
- toast.error("Could not load full practice details")
- } finally {
- setLoadingPracticeForm(false)
- }
- }
-
- const practiceFieldErrors = useMemo(() => {
- const title = practiceForm.title.trim()
- return {
- title: title ? undefined : "Title is required.",
- }
- }, [practiceForm.title])
-
- const practiceCanSave = !practiceFieldErrors.title
-
- const handleSavePractice = async () => {
- if (!practiceDialog.open) return
- if (!practiceCanSave) {
- setPracticeSubmitAttempted(true)
- return
- }
- setSavingPractice(true)
- try {
- if (practiceDialog.mode === "create") {
- await createPractice({
- sub_course_id: practiceDialog.subModuleId,
- title: practiceForm.title.trim(),
- description: practiceForm.description.trim(),
- persona: practiceForm.persona.trim() || undefined,
- })
- toast.success("Practice created")
- } else if (practiceDialog.practiceId) {
- await updateQuestionSet(practiceDialog.practiceId, {
- title: practiceForm.title.trim(),
- description: practiceForm.description.trim() || undefined,
- persona: practiceForm.persona.trim() || undefined,
- intro_video_url: practiceForm.introVideoUrl.trim() || undefined,
- passing_score: Number.isFinite(practiceForm.passingScore) ? practiceForm.passingScore : undefined,
- time_limit_minutes: Number.isFinite(practiceForm.timeLimitMinutes) ? practiceForm.timeLimitMinutes : undefined,
- shuffle_questions: practiceForm.shuffleQuestions,
- })
- toast.success("Practice updated")
- }
- setPracticeDialog({ open: false })
- setPracticeSubmitAttempted(false)
- setPracticeFormTouched(false)
- resetPracticeForm()
- await loadHierarchy()
- } catch (error) {
- console.error("Failed to save practice:", error)
- toast.error("Failed to save practice")
- } finally {
- setSavingPractice(false)
- }
- }
-
- const handlePracticeIntroVideoFileChange = async (event: ChangeEvent) => {
- const file = event.target.files?.[0]
- event.target.value = ""
- if (!file) return
- setUploadingPracticeIntroVideo(true)
- try {
- const uploadRes = await uploadVideoFile(file, {
- title: practiceForm.title.trim() || file.name.replace(/\.[^.]+$/, "") || "Practice intro",
- description: practiceForm.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")
- setPracticeForm((prev) => ({ ...prev, introVideoUrl: finalUrl }))
- toast.success("Intro video uploaded")
- } catch (error) {
- console.error("Failed to upload intro video:", error)
- toast.error("Failed to upload intro video")
- } finally {
- setUploadingPracticeIntroVideo(false)
- }
- }
-
- const openCreateQuestionDialog = (practiceId: number) => {
- setQuestionSubmitAttempted(false)
- setQuestionFormTouched(false)
- resetQuestionForm()
- setQuestionDialog({ open: true, mode: "create", practiceId })
- }
-
- const openEditQuestionDialog = async (practiceId: number, question: QuestionSetQuestion) => {
- setQuestionSubmitAttempted(false)
- setQuestionFormTouched(false)
- const qid = question.question_id ?? question.id
- resetQuestionForm()
- setLoadingQuestionEditId(qid)
- try {
- const detail = questionDetailById[qid] ?? (await getQuestionById(qid)).data?.data
- if (!detail) {
- toast.error("Could not load question details")
- return
- }
- setQuestionDetailById((prev) => ({ ...prev, [qid]: detail }))
- const sortedOpts = (detail.options ?? []).slice().sort((a, b) => a.option_order - b.option_order)
- const shortAnswer =
- Array.isArray(detail.short_answers) && detail.short_answers.length > 0
- ? typeof detail.short_answers[0] === "string"
- ? detail.short_answers[0]
- : detail.short_answers[0]?.acceptable_answer ?? ""
- : ""
- const qt = detail.question_type
- let questionType: PracticeQuestionEditorValue["questionType"] = "MCQ"
- if (qt === "TRUE_FALSE") questionType = "TRUE_FALSE"
- else if (qt === "SHORT" || qt === "SHORT_ANSWER") questionType = "SHORT"
- else if (qt === "AUDIO") questionType = "AUDIO"
- const difficultyRaw = detail.difficulty_level
- const difficultyLevel =
- difficultyRaw === "EASY" || difficultyRaw === "MEDIUM" || difficultyRaw === "HARD" ? difficultyRaw : "EASY"
-
- let options: PracticeQuestionEditorValue["options"]
- if (questionType === "TRUE_FALSE") {
- const trueRow =
- sortedOpts.find((o) => /\btrue\b/i.test((o.option_text ?? "").trim())) ?? sortedOpts[0]
- const falseRow =
- sortedOpts.find((o) => /\bfalse\b/i.test((o.option_text ?? "").trim())) ?? sortedOpts[1]
- const correctIsTrue =
- trueRow?.is_correct === true
- ? true
- : falseRow?.is_correct === true
- ? false
- : true
- options = [
- { text: "True", isCorrect: correctIsTrue },
- { text: "False", isCorrect: !correctIsTrue },
- ]
- } else {
- options =
- sortedOpts.length > 0
- ? sortedOpts.map((o) => ({
- text: o.option_text ?? "",
- isCorrect: !!o.is_correct,
- }))
- : createEmptyPracticeQuestionDraft().options
- if (!options.some((o) => o.isCorrect) && options.length > 0) {
- options = options.map((o, i) => ({ ...o, isCorrect: i === 0 }))
- }
- }
-
- setQuestionDraft({
- questionText: detail.question_text ?? "",
- questionType,
- difficultyLevel,
- points: detail.points && detail.points > 0 ? detail.points : 1,
- tips: detail.tips ?? "",
- explanation: detail.explanation ?? "",
- options,
- voicePrompt: detail.voice_prompt ?? "",
- sampleAnswerVoicePrompt: detail.sample_answer_voice_prompt ?? "",
- audioCorrectAnswerText: detail.audio_correct_answer_text ?? "",
- shortAnswer,
- imageUrl: detail.image_url ?? "",
- })
- // Open only after the same form shape as create is fully populated (no empty-state flash).
- setQuestionDialog({ open: true, mode: "edit", practiceId, questionId: qid })
- } catch (error) {
- console.error("Failed to load question detail:", error)
- toast.error("Could not load question details")
- } finally {
- setLoadingQuestionEditId(null)
- }
- }
-
- const buildQuestionPayload = (): CreateQuestionRequest => {
- const d = questionDraft
- const payload: CreateQuestionRequest = {
- question_text: d.questionText.trim(),
- question_type: d.questionType,
- difficulty_level: d.difficultyLevel,
- points: Number(d.points) || 1,
- tips: d.tips.trim() || undefined,
- explanation: d.explanation.trim() || undefined,
- image_url: d.imageUrl.trim() || undefined,
- voice_prompt: d.voicePrompt.trim() || undefined,
- sample_answer_voice_prompt: d.sampleAnswerVoicePrompt.trim() || undefined,
- audio_correct_answer_text: d.audioCorrectAnswerText.trim() || undefined,
- status: "PUBLISHED",
- }
- if (d.questionType === "SHORT") {
- payload.short_answers = d.shortAnswer.trim()
- ? [
- { acceptable_answer: d.shortAnswer.trim(), match_type: "EXACT" },
- { acceptable_answer: d.shortAnswer.trim(), match_type: "CASE_INSENSITIVE" },
- ]
- : undefined
- return payload
- }
- if (d.questionType === "TRUE_FALSE") {
- const trueCorrect = d.options[0]?.isCorrect === true && d.options[1]?.isCorrect !== true
- payload.options = [
- { option_order: 1, option_text: "True", is_correct: trueCorrect },
- { option_order: 2, option_text: "False", is_correct: !trueCorrect },
- ]
- return payload
- }
- if (d.questionType === "MCQ") {
- const filtered = d.options.filter((o) => o.text.trim())
- payload.options = filtered.map((o, idx) => ({
- option_order: idx + 1,
- option_text: o.text.trim(),
- is_correct: o.isCorrect,
- }))
- }
- return payload
- }
-
- const questionFieldErrors = useMemo(() => {
- const errors: {
- questionText?: string
- points?: string
- shortAnswer?: string
- options?: string
- correctOption?: string
- } = {}
- const d = questionDraft
- if (!d.questionText.trim()) errors.questionText = "Question text is required."
- const pts = Number(d.points)
- if (!Number.isFinite(pts) || pts < 1) errors.points = "Enter a valid number (minimum 1)."
- if (d.questionType === "SHORT" && !d.shortAnswer.trim()) {
- errors.shortAnswer = "Expected answer is required for short-answer questions."
- }
- if (d.questionType === "MCQ") {
- const filled = d.options.filter((o) => o.text.trim()).length
- if (filled < 2) errors.options = "Enter at least two non-empty options."
- const correctIdx = d.options.findIndex((o) => o.isCorrect)
- if (correctIdx >= 0 && !d.options[correctIdx]?.text?.trim()) {
- errors.correctOption = "The marked correct option must include text."
- }
- }
- return errors
- }, [questionDraft])
-
- const questionCanSave = Object.keys(questionFieldErrors).length === 0
-
- const handleSaveQuestion = async () => {
- if (!questionDialog.open) return
- if (!questionCanSave) {
- setQuestionSubmitAttempted(true)
- return
- }
- setSavingQuestion(true)
- try {
- const payload = buildQuestionPayload()
- if (questionDialog.mode === "create") {
- const created = await createQuestion(payload)
- const questionId = created.data?.data?.id
- if (!questionId) throw new Error("Missing created question id")
- await addQuestionToSet(questionDialog.practiceId, { question_id: questionId })
- toast.success("Question created")
- } else if (questionDialog.questionId) {
- await updateQuestion(questionDialog.questionId, payload)
- toast.success("Question updated")
- }
- setQuestionDialog({ open: false })
- setQuestionSubmitAttempted(false)
- setQuestionFormTouched(false)
- resetQuestionForm()
- await Promise.all([
- loadPracticeQuestionsIfNeeded(questionDialog.practiceId, true),
- loadHierarchy(),
- ])
- } catch (error) {
- console.error("Failed to save question:", error)
- toast.error("Failed to save question")
- } finally {
- setSavingQuestion(false)
- }
- }
-
- const handleDeletePracticeConfirmed = async () => {
- if (!practiceTargetDelete) return
- setDeletingPractice(true)
- try {
- await deleteQuestionSet(practiceTargetDelete.id)
- toast.success("Practice deleted")
- setPracticeTargetDelete(null)
- await loadHierarchy()
- } catch (error) {
- console.error("Failed to delete practice:", error)
- toast.error("Failed to delete practice")
- } finally {
- setDeletingPractice(false)
- }
- }
-
- const handleDeleteQuestionConfirmed = async () => {
- if (!questionTargetDelete) return
- setDeletingQuestion(true)
- try {
- await deleteQuestion(questionTargetDelete.id)
- toast.success("Question deleted")
- await Promise.all([
- loadPracticeQuestionsIfNeeded(questionTargetDelete.practiceId, true),
- loadHierarchy(),
- ])
- setQuestionTargetDelete(null)
- } catch (error) {
- console.error("Failed to delete question:", error)
- toast.error("Failed to delete question")
- } finally {
- setDeletingQuestion(false)
- }
- }
-
- const toggleLessonCard = (smKey: string, lessonId: number) => {
- setSubModuleCardSelection((prev) => {
- const cur = prev[smKey] ?? { lessonId: null, practiceId: null }
- const nextLessonId = cur.lessonId === lessonId ? null : lessonId
- return { ...prev, [smKey]: { ...cur, lessonId: nextLessonId } }
- })
- }
-
- const togglePracticeCard = (smKey: string, practiceId: number) => {
- const currentPracticeId = subModuleCardSelection[smKey]?.practiceId ?? null
- const nextPracticeId = currentPracticeId === practiceId ? null : practiceId
- setSubModuleCardSelection((prev) => {
- const cur = prev[smKey] ?? { lessonId: null, practiceId: null }
- return { ...prev, [smKey]: { ...cur, practiceId: nextPracticeId } }
- })
- if (nextPracticeId !== null) void loadPracticeQuestionsIfNeeded(nextPracticeId)
- }
-
- return (
-
-
-
-
-
-
-
-
-
Human Language Content
-
- Manage CEFR learning paths from A1 to C3 with quick lesson and practice oversight.
-
-
-
-
-
- {selectedCourses.length} path{selectedCourses.length === 1 ? "" : "s"}
-
-
- {subCategories.length} sub-categor{subCategories.length === 1 ? "y" : "ies"}
-
-
- {visibleCefrLevels.length} level{visibleCefrLevels.length === 1 ? "" : "s"}
-
-
-
-
-
-
-
- Filters
-
-
-
- Subcategory
-
- setSelectedSubCategoryId(e.target.value === "ALL" ? "ALL" : Number(e.target.value))
- }
- >
- All subcategories
- {subCategories.map((subCategory) => (
-
- {subCategory.sub_category_name}
-
- ))}
-
-
-
- Course
-
- setSelectedCourseId(e.target.value === "ALL" ? "ALL" : Number(e.target.value))
- }
- >
- All courses
- {availableCourses.map((course) => (
-
- {course.course_name}
-
- ))}
-
-
-
- Fetch lessons by level
- setSelectedLevel(e.target.value as CefrLevel | "ALL")}
- >
- ALL LEVELS
- {visibleCefrLevels.map((level) => (
-
- {level}
-
- ))}
-
-
-
-
-
- {loading ? (
-
-
- Loading human language lessons...
-
- ) : (
-
- {availableCourses.length === 0 ? (
-
-
-
Sub-category Management
-
-
- setQuickSearch(e.target.value)}
- />
-
-
-
-
-
-
-
-
No sub-categories yet
-
- Create your first human-language path. Level listing will appear automatically after creation.
-
-
- setQuickSubCategoryName(e.target.value)}
- />
- setQuickCourseName(e.target.value)}
- />
-
- {quickCreating ? "Creating..." : "Add your first sub-category"}
-
-
-
-
-
- ) : null}
-
- {availableCourses.length > 0
- ? selectedCourses.map((course: HumanLanguageCourseTree) => {
- const courseLevels = CEFR_LEVELS.filter((level) => {
- if (level === "A1") return true
- const node = course.levels.find((item) => item.level.toUpperCase() === level)
- return (node?.modules?.length ?? 0) > 0
- }).filter((level) => selectedLevel === "ALL" || selectedLevel === level)
-
- const pathCollapsed = collapsedPathIds.includes(course.course_id)
- const levelsDone = levelsWithContentForCourse(course)
- const nextCefrForPath = CEFR_LEVELS.find((l) => !levelsDone.includes(l))
- const pathNextLevelLoading = creatingKey?.startsWith(`next-level-${course.course_id}-`) ?? false
- const pathLevelsFull = levelsDone.length >= CEFR_LEVELS.length
-
- return (
-
-
-
togglePathCollapsed(course.course_id)}
- >
- {pathCollapsed ? (
-
- ) : (
-
- )}
- {course.course_name}
-
- {levelsDone.length}/{CEFR_LEVELS.length} levels
-
-
-
- handleCreateNextLevelForCourse(course.course_id)}
- >
- {pathNextLevelLoading ? "Creating…" : "Add next CEFR level"}
-
-
-
- {!pathCollapsed ? (
-
- {courseLevels.length === 0 ? (
- No levels match the current level filter.
- ) : (
- courseLevels.map((level) => {
- 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 canRemoveLevel = levelRemoveIds.length > 0
- return (
-
-
- toggleLevel(levelKey)}
- >
- {collapsedLevels.includes(levelKey) ? : }
- {level}
-
- {modules.length} module(s)
-
-
-
- requestRemove({
- ids: levelRemoveIds,
- key: `level-${course.course_id}-${level}`,
- successMessage: `Level ${level} removed`,
- title: `Remove level ${level}?`,
- description: `This will permanently delete all modules and sub-modules under ${level} for “${course.course_name}”. This action cannot be undone.`,
- })
- }
- >
-
- Remove
-
-
- {!collapsedLevels.includes(levelKey) ? (
-
-
-
handleCreateModule(course.course_id, level, modules)}
- disabled={creatingKey === `module-${course.course_id}-${level}`}
- >
- {creatingKey === `module-${course.course_id}-${level}` ? (
-
- ) : (
-
- )}
- Add Module
-
-
- {modules.length === 0 ? (
-
No modules yet. Use “Add Module” to start.
- ) : (
- modules.map((module) => (
-
- {(() => {
- const moduleCollapsed = collapsedModuleIds.includes(module.id)
- return (
- <>
-
-
toggleModuleCollapsed(module.id)}
- className="flex min-w-0 flex-1 items-center gap-2 text-left"
- >
- {moduleCollapsed ? (
-
- ) : (
-
- )}
- Module: {module.title}
-
- {module.sub_modules.length} sub-module(s)
-
-
-
-
- handleCreateSubModule(course.course_id, level, module.title, module.sub_modules)
- }
- disabled={creatingKey === `submodule-${course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}`}
- >
- {creatingKey === `submodule-${course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}` ? (
-
- ) : (
-
- )}
- Add Sub-module
-
-
- requestRemove({
- ids: module.sub_modules.map((s) => s.id),
- key: `module-${module.id}`,
- successMessage: `Module ${module.title} removed`,
- title: `Remove ${module.title}?`,
- description:
- "All sub-modules in this module will be permanently deleted. This action cannot be undone.",
- })
- }
- >
-
- Remove
-
-
-
- {!moduleCollapsed ? module.sub_modules.map((subModule) => {
- const subModuleCollapsed = collapsedSubModuleIds.includes(subModule.id)
- const smKey = `${course.course_id}-${subModule.id}`
- const panelTab = subModulePanelTab[smKey] ?? "lessons"
- const cardSel = getSubModuleSelection(smKey)
- const lessonRows: LearningPathVideo[] = [...subModule.videos].sort(
- (a, b) => a.display_order - b.display_order,
- )
- const practiceRows: LearningPathPractice[] = [...subModule.practices].sort(
- (a, b) => (a.display_order ?? 0) - (b.display_order ?? 0),
- )
- const selectedLesson =
- cardSel.lessonId !== null
- ? lessonRows.find((v) => v.id === cardSel.lessonId) ?? null
- : null
- const selectedPracticeMeta =
- cardSel.practiceId !== null
- ? practiceRows.find((p) => p.id === cardSel.practiceId) ?? null
- : null
- const practiceFetch =
- cardSel.practiceId !== null ? practiceQuestionsState[cardSel.practiceId] : undefined
- return (
-
-
-
toggleSubModuleCollapsed(subModule.id)}
- className="flex min-w-0 flex-1 items-center gap-2 text-left"
- >
- {subModuleCollapsed ? (
-
- ) : (
-
- )}
-
- Sub-module: {subModule.title}
-
-
- {categoryId ? (
-
-
-
- Open editor
-
-
-
- requestRemove({
- ids: [subModule.id],
- key: `submodule-${subModule.id}`,
- successMessage: `Sub-module ${subModule.title} removed`,
- title: `Remove ${subModule.title}?`,
- description:
- "This sub-module will be permanently deleted. This action cannot be undone.",
- })
- }
- >
-
- Remove
-
-
- ) : null}
-
- {!subModuleCollapsed ? (
- <>
-
-
-
-
- setSubModulePanelTab((prev) => ({ ...prev, [smKey]: "lessons" }))
- }
- className={`relative pb-3 pt-2 text-sm font-semibold transition-colors ${
- panelTab === "lessons"
- ? "text-brand-600"
- : "text-grayScale-400 hover:text-grayScale-700"
- }`}
- >
- Lessons
- {panelTab === "lessons" ? (
-
- ) : null}
-
-
- setSubModulePanelTab((prev) => ({ ...prev, [smKey]: "practices" }))
- }
- className={`relative pb-3 pt-2 text-sm font-semibold transition-colors ${
- panelTab === "practices"
- ? "text-brand-600"
- : "text-grayScale-400 hover:text-grayScale-700"
- }`}
- >
- Practices
- {panelTab === "practices" ? (
-
- ) : null}
-
-
- {panelTab === "practices" ? (
-
openCreatePracticeDialog(course.course_id, subModule.id)}
- >
-
- New practice
-
- ) : null}
-
-
-
-
- {panelTab === "lessons" ? (
- lessonRows.length === 0 ? (
-
- No lesson videos yet. Use{" "}
- Open editor to add
- videos.
-
- ) : (
-
-
- {lessonRows.map((v, idx) => {
- const isActive = cardSel.lessonId === v.id
- return (
-
toggleLessonCard(smKey, v.id)}
- className={cn(
- "flex flex-col gap-1.5 rounded-xl border bg-white p-3 text-left shadow-sm transition-all hover:border-brand-200 hover:shadow-md",
- isActive
- ? "border-brand-400 ring-2 ring-brand-400/30"
- : "border-grayScale-100",
- )}
- >
-
-
-
-
-
-
- {v.title}
-
-
- Lesson {idx + 1} · {formatDurationSeconds(v.duration ?? 0)} · Order{" "}
- {v.display_order}
-
-
-
-
- )
- })}
-
- {selectedLesson ? (
-
-
- Lesson content
-
-
- {selectedLesson.title}
-
-
-
-
Display order
-
- {selectedLesson.display_order}
-
-
-
-
Duration
-
- {formatDurationSeconds(selectedLesson.duration ?? 0)}
-
-
-
-
Video
-
- {selectedLesson.video_url ? (
-
- {selectedLesson.video_url}
-
- ) : (
-
- No video URL set — use Open editor to add one.
-
- )}
- {selectedLesson.video_url
- ? renderMediaPreview(
- selectedLesson.video_url,
- "video",
- "mt-3",
- "Video preview",
- )
- : null}
-
-
-
-
- ) : (
-
- Select a lesson card to view full content.
-
- )}
-
- )
- ) : practiceRows.length === 0 ? (
-
- No practices yet. Use{" "}
- Open editor to create a
- practice.
-
- ) : (
-
-
- {practiceRows.map((p, pIdx) => {
- const isActive = cardSel.practiceId === p.id
- return (
-
togglePracticeCard(smKey, p.id)}
- className={cn(
- "flex flex-col gap-1.5 rounded-xl border bg-white p-3 text-left shadow-sm transition-all hover:border-brand-200 hover:shadow-md",
- isActive
- ? "border-brand-400 ring-2 ring-brand-400/30"
- : "border-grayScale-100",
- )}
- >
-
-
-
-
-
-
- {p.title}
-
-
- Practice {pIdx + 1}
-
-
-
- {(p.status ?? "—").replace(/_/g, " ").toLowerCase()}
-
-
- {p.question_count} Q · order {p.display_order ?? "—"}
-
-
-
-
- {
- e.stopPropagation()
- openEditPracticeDialog(subModule.id, p)
- }}
- >
- Edit
-
- {
- e.stopPropagation()
- setPracticeTargetDelete({ id: p.id, title: p.title })
- }}
- >
- Delete
-
-
-
-
- )
- })}
-
- {cardSel.practiceId !== null && selectedPracticeMeta ? (
-
-
-
-
-
- Question bank
-
-
- {selectedPracticeMeta.title}
-
- {practiceFetch?.status === "ok" ? (
-
- {practiceFetch.totalCount}{" "}
- {practiceFetch.totalCount === 1 ? "question" : "questions"} in this
- practice
-
- ) : null}
-
-
-
openCreateQuestionDialog(selectedPracticeMeta.id)}
- >
-
- Add question
-
- {practiceFetch?.status === "ok" ? (
-
- {practiceFetch.questions.length} loaded
-
- ) : null}
-
-
-
-
- {!practiceFetch || practiceFetch.status === "loading" ? (
-
-
- Loading questions…
-
- ) : practiceFetch.status === "error" ? (
-
-
-
-
-
{practiceFetch.message}
-
void loadPracticeQuestionsIfNeeded(selectedPracticeMeta.id)}
- >
- Retry
-
-
-
-
- ) : practiceFetch.questions.length === 0 ? (
-
-
-
- No questions in this practice yet.
-
-
- Add them via Open editor .
-
-
- ) : (
-
- {practiceFetch.questions.map((q, qIdx) => {
- const qType = String(q.question_type ?? "—")
- const embeddedUrls = extractUrls(q.question_text || "")
- return (
-
-
-
-
- {qIdx + 1}
-
-
-
-
-
- {formatQuestionTypeLabel(qType)}
-
- {q.points != null && q.points > 0 ? (
-
- {q.points} pts
-
- ) : null}
- {q.difficulty_level ? (
-
- {q.difficulty_level}
-
- ) : null}
-
-
-
- void openEditQuestionDialog(
- selectedPracticeMeta.id,
- q,
- )
- }
- >
- {loadingQuestionEditId ===
- (q.question_id ?? q.id) ? (
-
- ) : null}
- Edit
-
-
- setQuestionTargetDelete({
- id: q.question_id ?? q.id,
- practiceId: selectedPracticeMeta.id,
- text: q.question_text || "Question",
- })
- }
- >
- Delete
-
-
-
-
-
- Prompt
-
-
- {q.question_text?.trim() || (
- No prompt text
- )}
-
-
- {embeddedUrls.length > 0 ? (
-
-
-
- Media in prompt
-
-
- {embeddedUrls.map((u) => (
-
{renderMediaPreview(u, undefined, "", "Embedded link")}
- ))}
-
-
- ) : null}
- {q.tips ? (
-
-
-
- Learner tip
-
-
{q.tips}
-
- ) : null}
- {q.image_url ||
- q.voice_prompt ||
- q.sample_answer_voice_prompt ? (
-
-
- Assets
-
-
- {q.image_url
- ? renderMediaPreview(q.image_url, "image", "", "Image")
- : null}
- {q.voice_prompt
- ? renderMediaPreview(q.voice_prompt, "audio", "", "Voice prompt")
- : null}
- {q.sample_answer_voice_prompt
- ? renderMediaPreview(
- q.sample_answer_voice_prompt,
- "audio",
- "",
- "Sample answer (audio)",
- )
- : null}
-
-
- ) : null}
- {q.audio_correct_answer_text ? (
-
-
- Sample answer text
-
-
- {q.audio_correct_answer_text}
-
-
- ) : null}
-
-
-
- )
- })}
-
- )}
- {practiceFetch?.status === "ok" &&
- practiceFetch.totalCount > practiceFetch.questions.length ? (
-
- Showing {practiceFetch.questions.length} of{" "}
- {practiceFetch.totalCount} questions.
-
- ) : null}
-
-
- ) : (
-
- Select a practice card to view its questions.
-
- )}
-
- )}
-
- >
- ) : null}
-
- )
- }) : null}
- >
- )
- })()}
-
- ))
- )}
-
- ) : null}
-
- )
- })
- )}
-
- ) : null}
-
- )
- })
- : null}
-
- )}
-
-
!open && setPendingRemove(null)}>
-
-
- {pendingRemove?.title ?? "Confirm removal"}
- {pendingRemove?.description}
-
-
- setPendingRemove(null)}>
- Cancel
-
- void executePendingRemove()}>
- Remove
-
-
-
-
-
-
{
- if (!open) {
- setPracticeDialog({ open: false })
- setPracticeSubmitAttempted(false)
- setPracticeFormTouched(false)
- }
- }}
- >
-
-
- {practiceDialog.open && practiceDialog.mode === "edit" ? "Edit Practice" : "Create Practice"}
-
- Manage full practice (question set) metadata directly from this page.
- {!practiceCanSave ? (
- Required fields must be completed before you can save.
- ) : null}
-
-
- {loadingPracticeForm ? (
-
-
- Loading practice details...
-
- ) : (
-
-
-
Title
-
{
- setPracticeFormTouched(true)
- setPracticeForm((p) => ({ ...p, title: e.target.value }))
- }}
- className={cn(
- "h-10 w-full rounded-md border px-3 text-sm",
- (practiceSubmitAttempted || practiceFormTouched) && practiceFieldErrors.title
- ? "border-red-300 ring-1 ring-red-200"
- : "border-grayScale-200",
- )}
- placeholder="Practice title"
- aria-invalid={Boolean(
- (practiceSubmitAttempted || practiceFormTouched) && practiceFieldErrors.title,
- )}
- />
- {(practiceSubmitAttempted || practiceFormTouched) && practiceFieldErrors.title ? (
-
{practiceFieldErrors.title}
- ) : null}
-
-
- Description
-
-
- Persona
- {
- setPracticeFormTouched(true)
- setPracticeForm((p) => ({ ...p, persona: e.target.value }))
- }}
- className="h-10 w-full rounded-md border border-grayScale-200 px-3 text-sm"
- placeholder="Optional persona"
- />
-
-
-
-
- Shuffle questions
- {
- setPracticeFormTouched(true)
- setPracticeForm((p) => ({ ...p, shuffleQuestions: !p.shuffleQuestions }))
- }}
- className={`relative inline-flex h-6 w-11 rounded-full transition-colors ${
- practiceForm.shuffleQuestions ? "bg-brand-500" : "bg-grayScale-300"
- }`}
- >
-
-
-
-
- )}
-
- setPracticeDialog({ open: false })}>
- Cancel
-
- void handleSavePractice()} disabled={savingPractice || !practiceCanSave || loadingPracticeForm}>
- {savingPractice ? "Saving..." : "Save"}
-
-
-
-
-
-
!open && setPracticeTargetDelete(null)}>
-
-
- Delete practice?
-
- {practiceTargetDelete ? `This will permanently delete "${practiceTargetDelete.title}".` : ""}
-
-
-
- setPracticeTargetDelete(null)}>
- Cancel
-
- void handleDeletePracticeConfirmed()}
- disabled={deletingPractice}
- >
- {deletingPractice ? "Deleting..." : "Delete"}
-
-
-
-
-
-
{
- if (!open) {
- setQuestionDialog({ open: false })
- setQuestionSubmitAttempted(false)
- setQuestionFormTouched(false)
- }
- }}
- >
-
-
-
-
- {questionDialog.open && questionDialog.mode === "edit" ? "Edit question" : "Add question"}
-
-
- Same layout as Add New Practice → Step 3: Questions . Add MCQ,
- True/False, Short Answer, or Audio; optional tips and voice prompts below.
- {!questionCanSave ? (
-
- Fix the highlighted fields before saving. Save stays disabled until the form is valid.
-
- ) : null}
-
-
-
-
-
-
-
Step 3: Questions
-
- Add MCQ, True/False, Short Answer, or Audio items. Use the full width for stems and options.
-
-
-
-
-
-
- {
- setQuestionFormTouched(true)
- setQuestionDraft(next)
- }}
- fieldErrors={questionFieldErrors}
- showFieldErrors={questionSubmitAttempted || questionFormTouched}
- mediaBusy={savingQuestion}
- />
-
-
-
-
- setQuestionDialog({ open: false })} className="sm:mr-auto">
- Cancel
-
- void handleSaveQuestion()}
- disabled={savingQuestion || !questionCanSave}
- >
- {savingQuestion ? "Saving..." : "Save question"}
-
-
-
-
-
-
!open && setQuestionTargetDelete(null)}>
-
-
- Delete question?
-
- {questionTargetDelete ? `This will permanently delete "${questionTargetDelete.text}".` : ""}
-
-
-
- setQuestionTargetDelete(null)}>
- Cancel
-
- void handleDeleteQuestionConfirmed()}
- disabled={deletingQuestion}
- >
- {deletingQuestion ? "Deleting..." : "Delete"}
-
-
-
-
-
- )
-}
-
diff --git a/src/pages/content-management/HumanLanguageSubModulePage.tsx b/src/pages/content-management/HumanLanguageSubModulePage.tsx
index 6408dac..a941782 100644
--- a/src/pages/content-management/HumanLanguageSubModulePage.tsx
+++ b/src/pages/content-management/HumanLanguageSubModulePage.tsx
@@ -1,1076 +1,1298 @@
-import { useEffect, useState } from "react"
-import { Link, useParams, useNavigate } from "react-router-dom"
-import { ArrowLeft, Plus, FileText, Layers, Edit, Trash2, X, Video, MoreVertical, Play } from "lucide-react"
-import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
+import { useCallback, useEffect, useMemo, useState } from "react"
+import { Link, useLocation, useNavigate, useParams } from "react-router-dom"
+import { ArrowLeft, BookOpen, Eye, FileText, Plus, Search, Trophy, Video } from "lucide-react"
+import { toast } from "sonner"
import { Card } from "../../components/ui/card"
-import alertSrc from "../../assets/Alert.svg"
-import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button"
+import { Badge } from "../../components/ui/badge"
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../components/ui/dialog"
import { Input } from "../../components/ui/input"
-import {
- getSubModulesByCourse,
- getQuestionSetsByOwner,
- getVideosBySubModule,
- updatePractice,
- deleteQuestionSet,
- createCourseVideo,
- updateSubCourseVideo,
- deleteSubCourseVideo,
- getVimeoSample,
-} from "../../api/courses.api"
-import { uploadVideoFile } from "../../api/files.api"
-import type {
- SubCourse,
- QuestionSet,
- SubCourseVideo,
- VimeoSampleVideo,
- VideoStatus,
- VideoVisibility,
-} from "../../types/course.types"
+import { Select } from "../../components/ui/select"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
+import { Textarea } from "../../components/ui/textarea"
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
+import { cn } from "../../lib/utils"
+import {
+ getLessonsBySubModule,
+ getQuestionSetsByOwner,
+ getSubModuleLessonById,
+ resolveSubModuleForCourse,
+ getVideosBySubModule,
+ softDeleteSubModuleLesson,
+ updateSubModuleLesson,
+} from "../../api/courses.api"
+import type {
+ QuestionSet,
+ QuestionSetStatus,
+ SubCourse,
+ SubCourseVideo,
+ SubModuleLesson,
+ SubModuleLessonDetail,
+} from "../../types/course.types"
-type TabType = "lesson" | "practice"
-type StatusFilter = "all" | "published" | "draft" | "archived"
+type ContentTab = "lessons" | "practices" | "capstones" | "videos"
+
+function formatTableDate(dateStr: string) {
+ return new Date(dateStr).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ })
+}
+
+function formatVideoDuration(seconds: number) {
+ if (!Number.isFinite(seconds) || seconds < 0) return "—"
+ const m = Math.floor(seconds / 60)
+ const s = Math.floor(seconds % 60)
+ return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`
+}
+
+function getRelativeTime(dateStr: string) {
+ const now = new Date()
+ const date = new Date(dateStr)
+ const diffMs = now.getTime() - date.getTime()
+ const diffMins = Math.floor(diffMs / 60000)
+ const diffHours = Math.floor(diffMs / 3600000)
+ const diffDays = Math.floor(diffMs / 86400000)
+
+ if (diffMins < 1) return "Just now"
+ if (diffMins < 60) return `${diffMins}m ago`
+ if (diffHours < 24) return `${diffHours}h ago`
+ if (diffDays < 7) return `${diffDays}d ago`
+ return formatTableDate(dateStr)
+}
+
+function normalizeSetType(set: QuestionSet) {
+ return String(set.set_type ?? "").toUpperCase()
+}
+
+function partitionSets(sets: QuestionSet[]) {
+ const practices: QuestionSet[] = []
+ const capstones: QuestionSet[] = []
+ for (const s of sets) {
+ const t = normalizeSetType(s)
+ if (t === "PRACTICE") practices.push(s)
+ else capstones.push(s)
+ }
+ return { practices, capstones }
+}
+
+type LessonActiveFilter = "all" | "active" | "inactive"
+
+function textMatchesQuery(text: string | null | undefined, q: string) {
+ const needle = q.trim().toLowerCase()
+ if (!needle) return true
+ return (text ?? "").toLowerCase().includes(needle)
+}
+
+function filterQuestionSets(items: QuestionSet[], search: string, statusFilter: "all" | QuestionSetStatus) {
+ return items.filter((item) => {
+ if (statusFilter !== "all" && item.status !== statusFilter) return false
+ const needle = search.trim()
+ if (!needle) return true
+ return textMatchesQuery(item.title, needle) || textMatchesQuery(item.description, needle)
+ })
+}
+
+function filterLessons(items: SubModuleLesson[], search: string, activeFilter: LessonActiveFilter) {
+ return items.filter((lesson) => {
+ if (activeFilter === "active" && !lesson.is_active) return false
+ if (activeFilter === "inactive" && lesson.is_active) return false
+ const needle = search.trim()
+ if (!needle) return true
+ return textMatchesQuery(lesson.title, needle) || textMatchesQuery(lesson.description, needle)
+ })
+}
+
+function lessonStatusBadge(isActive: boolean) {
+ return isActive ? (
+
+ Active
+
+ ) : (
+
+ Inactive
+
+ )
+}
-/** Human Language–only sub-module editor: lesson (videos) + practice tabs; not used by general course flows. */
export function HumanLanguageSubModulePage() {
- const { categoryId, courseId, subModuleId } = useParams<{
+ const { categoryId, courseId, subModuleId } = useParams<{
categoryId: string
courseId: string
subModuleId: string
}>()
const navigate = useNavigate()
-
- const [subCourse, setSubCourse] = useState(null)
- const [loading, setLoading] = useState(true)
- const [error, setError] = useState(null)
+ const location = useLocation()
+ const isHumanLanguageRoute = location.pathname.includes("/content/human-language/")
- const [activeTab, setActiveTab] = useState("lesson")
- const [statusFilter] = useState("all")
-
- const [practices, setPractices] = useState([])
+ const [subCourse, setSubCourse] = useState(null)
+ const [loadingMeta, setLoadingMeta] = useState(true)
+ const [metaError, setMetaError] = useState(null)
+
+ const [activeTab, setActiveTab] = useState("practices")
+ const [sets, setSets] = useState([])
+ const [setsLoading, setSetsLoading] = useState(false)
+ const [lessons, setLessons] = useState([])
+ const [lessonsLoading, setLessonsLoading] = useState(false)
+ const [lessonDetailOpen, setLessonDetailOpen] = useState(false)
+ const [lessonDetailLoading, setLessonDetailLoading] = useState(false)
+ const [lessonDetail, setLessonDetail] = useState(null)
+ const [lessonEditMode, setLessonEditMode] = useState(false)
+ const [lessonUpdateSaving, setLessonUpdateSaving] = useState(false)
+ const [lessonSoftDeleteSaving, setLessonSoftDeleteSaving] = useState(false)
+ const [lessonSoftDeleteConfirmOpen, setLessonSoftDeleteConfirmOpen] = useState(false)
+ const [editLessonTitle, setEditLessonTitle] = useState("")
+ const [editLessonDescription, setEditLessonDescription] = useState("")
+ const [editLessonThumbnail, setEditLessonThumbnail] = useState("")
+ const [editLessonTeachingText, setEditLessonTeachingText] = useState("")
+ const [editLessonTeachingImageUrl, setEditLessonTeachingImageUrl] = useState("")
+ const [editLessonTeachingAudioUrl, setEditLessonTeachingAudioUrl] = useState("")
+ const [editLessonTeachingVideoUrl, setEditLessonTeachingVideoUrl] = useState("")
+ const [editLessonDisplayOrder, setEditLessonDisplayOrder] = useState(0)
+ const [editLessonIsActive, setEditLessonIsActive] = useState(true)
const [videos, setVideos] = useState([])
- const [practicesLoading, setPracticesLoading] = useState(false)
const [videosLoading, setVideosLoading] = useState(false)
- const [showEditPracticeModal, setShowEditPracticeModal] = useState(false)
- const [practiceToEdit, setPracticeToEdit] = useState(null)
- const [showDeleteModal, setShowDeleteModal] = useState(false)
- const [practiceToDelete, setPracticeToDelete] = useState(null)
- const [deleting, setDeleting] = useState(false)
+ const [practiceSearch, setPracticeSearch] = useState("")
+ const [practiceStatusFilter, setPracticeStatusFilter] = useState<"all" | QuestionSetStatus>("all")
+ const [capstoneSearch, setCapstoneSearch] = useState("")
+ const [capstoneStatusFilter, setCapstoneStatusFilter] = useState<"all" | QuestionSetStatus>("all")
+ const [lessonSearch, setLessonSearch] = useState("")
+ const [lessonActiveFilter, setLessonActiveFilter] = useState("all")
- const [title, setTitle] = useState("")
- const [description, setDescription] = useState("")
- const [persona, setPersona] = useState("")
- const [saving, setSaving] = useState(false)
- const [saveError, setSaveError] = useState(null)
-
- const [showAddVideoModal, setShowAddVideoModal] = useState(false)
- const [showEditVideoModal, setShowEditVideoModal] = useState(false)
- const [videoToEdit, setVideoToEdit] = useState(null)
- const [showDeleteVideoModal, setShowDeleteVideoModal] = useState(false)
- const [videoToDelete, setVideoToDelete] = useState(null)
- const [deletingVideo, setDeletingVideo] = useState(false)
- const [openVideoMenuId, setOpenVideoMenuId] = useState(null)
-
- const [videoTitle, setVideoTitle] = useState("")
- const [videoDescription, setVideoDescription] = useState("")
- const [videoUrl, setVideoUrl] = useState("")
- const [videoFile, setVideoFile] = useState(null)
- const [videoFileSize, setVideoFileSize] = useState(0)
- const [videoDuration, setVideoDuration] = useState(0)
- const [videoResolution, setVideoResolution] = useState("1080p")
- const [videoVisibility, setVideoVisibility] = useState("PUBLISHED")
- const [videoStatus, setVideoStatus] = useState("PUBLISHED")
- const [videoDisplayOrder, setVideoDisplayOrder] = useState(1)
-
- // Vimeo preview state
- const [showPreviewModal, setShowPreviewModal] = useState(false)
- const [previewIframe, setPreviewIframe] = useState("")
- const [previewVideo, setPreviewVideo] = useState(null)
- const [previewLoading, setPreviewLoading] = useState(false)
+ const numericCourseId = Number(courseId)
+ const numericSubModuleId = Number(subModuleId)
useEffect(() => {
- const fetchData = async () => {
- if (!subModuleId || !courseId) return
-
+ const run = async () => {
+ if (!subModuleId || !courseId || Number.isNaN(numericCourseId) || Number.isNaN(numericSubModuleId)) {
+ setMetaError("Invalid route parameters")
+ setLoadingMeta(false)
+ return
+ }
+ setLoadingMeta(true)
+ setMetaError(null)
try {
- const subCoursesRes = await getSubModulesByCourse(Number(courseId))
- const foundSubCourse = subCoursesRes.data.data.sub_courses?.find(
- (sc) => sc.id === Number(subModuleId)
- )
- setSubCourse(foundSubCourse ?? null)
- } catch (err) {
- console.error("Failed to fetch course data:", err)
- setError("Failed to load course")
+ const found =
+ (await resolveSubModuleForCourse(numericCourseId, numericSubModuleId)) ?? null
+ setSubCourse(found)
+ if (!found) setMetaError("Sub-module not found for this course")
+ } catch (e) {
+ console.error(e)
+ setMetaError("Failed to load sub-module")
+ setSubCourse(null)
+ toast.error("Failed to load sub-module")
} finally {
- setLoading(false)
+ setLoadingMeta(false)
}
}
+ void run()
+ }, [subModuleId, courseId, numericCourseId, numericSubModuleId])
- fetchData()
- }, [subModuleId, courseId])
-
- const fetchPractices = async () => {
- if (!subModuleId) return
- setPracticesLoading(true)
+ const fetchSets = useCallback(async () => {
+ if (!subModuleId || Number.isNaN(numericSubModuleId)) return
+ setSetsLoading(true)
try {
- const res = await getQuestionSetsByOwner("SUB_MODULE", Number(subModuleId))
- const raw = res.data.data
- const list = Array.isArray(raw) ? raw : raw?.question_sets ?? []
- setPractices(list)
- } catch (err) {
- console.error("Failed to fetch practices:", err)
+ const res = await getQuestionSetsByOwner("SUB_MODULE", numericSubModuleId)
+ const raw = res.data?.data
+ const list = Array.isArray(raw) ? raw : (raw as { question_sets?: QuestionSet[] })?.question_sets ?? []
+ setSets(list)
+ } catch (e) {
+ console.error(e)
+ toast.error("Failed to load question sets")
+ setSets([])
} finally {
- setPracticesLoading(false)
+ setSetsLoading(false)
}
- }
+ }, [subModuleId, numericSubModuleId])
- const fetchVideos = async () => {
- if (!subModuleId) return
+ const fetchVideos = useCallback(async () => {
+ if (!subModuleId || Number.isNaN(numericSubModuleId)) return
setVideosLoading(true)
try {
- const res = await getVideosBySubModule(Number(subModuleId))
- setVideos(res.data.data.videos ?? [])
- } catch (err) {
- console.error("Failed to fetch videos:", err)
+ const res = await getVideosBySubModule(numericSubModuleId)
+ setVideos(res.data?.data?.videos ?? [])
+ } catch (e) {
+ console.error(e)
+ toast.error("Failed to load videos")
+ setVideos([])
} finally {
setVideosLoading(false)
}
- }
+ }, [subModuleId, numericSubModuleId])
- useEffect(() => {
- if (activeTab === "practice") {
- fetchPractices()
- } else if (activeTab === "lesson") {
- fetchVideos()
- }
- }, [activeTab, subModuleId])
-
- const handleAddPractice = () => {
- navigate(`/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}/add-practice`)
- }
-
-
-
- const handleEditClick = (practice: QuestionSet) => {
- setPracticeToEdit(practice)
- setTitle(practice.title)
- setDescription(practice.description)
- setPersona(practice.persona || "")
- setSaveError(null)
- setShowEditPracticeModal(true)
- }
-
- const handleSaveEditPractice = async () => {
- if (!practiceToEdit) return
- setSaving(true)
- setSaveError(null)
+ const fetchLessons = useCallback(async () => {
+ if (!subModuleId || Number.isNaN(numericSubModuleId)) return
+ setLessonsLoading(true)
try {
- await updatePractice(practiceToEdit.id, {
- title,
- description,
- persona,
- })
- setShowEditPracticeModal(false)
- setPracticeToEdit(null)
- setTitle("")
- setDescription("")
- setPersona("")
- await fetchPractices()
- } catch (err) {
- console.error("Failed to update practice:", err)
- setSaveError("Failed to update practice")
+ const res = await getLessonsBySubModule(numericSubModuleId, { includeInactive: true })
+ const list = Array.isArray(res.data?.data) ? res.data.data : []
+ setLessons(list)
+ } catch (e) {
+ console.error(e)
+ toast.error("Failed to load lessons")
+ setLessons([])
} finally {
- setSaving(false)
+ setLessonsLoading(false)
}
- }
+ }, [subModuleId, numericSubModuleId])
- const handleDeleteClick = (practice: QuestionSet) => {
- setPracticeToDelete(practice)
- setShowDeleteModal(true)
- }
-
- const handleConfirmDelete = async () => {
- if (!practiceToDelete) return
- setDeleting(true)
+ const openLessonDetail = useCallback(async (lessonId: number) => {
+ setLessonDetailOpen(true)
+ setLessonDetailLoading(true)
+ setLessonEditMode(false)
+ setLessonDetail(null)
try {
- await deleteQuestionSet(practiceToDelete.id)
- setShowDeleteModal(false)
- setPracticeToDelete(null)
- await fetchPractices()
- } catch (err) {
- console.error("Failed to delete practice:", err)
+ const res = await getSubModuleLessonById(lessonId)
+ setLessonDetail(res.data?.data ?? null)
+ } catch (e) {
+ console.error(e)
+ toast.error("Failed to load lesson detail")
} finally {
- setDeleting(false)
+ setLessonDetailLoading(false)
}
+ }, [])
+
+ const startEditLesson = () => {
+ if (!lessonDetail) return
+ setEditLessonTitle(lessonDetail.title ?? "")
+ setEditLessonDescription(lessonDetail.description ?? "")
+ setEditLessonThumbnail(lessonDetail.thumbnail ?? "")
+ setEditLessonTeachingText(lessonDetail.teaching_text ?? "")
+ setEditLessonTeachingImageUrl(lessonDetail.teaching_image_url ?? "")
+ setEditLessonTeachingAudioUrl(lessonDetail.teaching_audio_url ?? "")
+ setEditLessonTeachingVideoUrl(lessonDetail.teaching_video_url ?? "")
+ setEditLessonDisplayOrder(lessonDetail.display_order ?? 0)
+ setEditLessonIsActive(lessonDetail.is_active)
+ setLessonEditMode(true)
}
- const handlePracticeClick = (practiceId: number) => {
- navigate(`/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}/practices/${practiceId}/questions`)
- }
-
- const handleAddVideo = () => {
- setVideoTitle("")
- setVideoDescription("")
- setVideoUrl("")
- setVideoFile(null)
- setVideoFileSize(0)
- setVideoDuration(0)
- setVideoResolution("1080p")
- setVideoVisibility("PUBLISHED")
- setVideoStatus("PUBLISHED")
- setVideoDisplayOrder(1)
- setSaveError(null)
- setShowAddVideoModal(true)
- }
-
- const handleVideoFileSelect = (file: File | null) => {
- setVideoFile(file)
- if (!file) {
- setVideoFileSize(0)
- setVideoDuration(0)
+ const handleUpdateLesson = async () => {
+ if (!lessonDetail) return
+ const title = editLessonTitle.trim()
+ if (!title) {
+ toast.error("Lesson title is required")
return
}
- setVideoFileSize(file.size)
- const video = document.createElement("video")
- const objectUrl = URL.createObjectURL(file)
- video.preload = "metadata"
- video.src = objectUrl
- video.onloadedmetadata = () => {
- setVideoDuration(Math.max(0, Math.round(video.duration || 0)))
- URL.revokeObjectURL(objectUrl)
- }
- video.onerror = () => {
- URL.revokeObjectURL(objectUrl)
- }
- }
-
- const handleSaveNewVideo = async () => {
- if (!subModuleId || !videoFile) return
- setSaving(true)
- setSaveError(null)
+ setLessonUpdateSaving(true)
try {
- const uploadRes = await uploadVideoFile(videoFile, {
- title: videoTitle.trim(),
- description: videoDescription.trim(),
+ const response = await updateSubModuleLesson(lessonDetail.id, {
+ title,
+ description: editLessonDescription.trim() || null,
+ thumbnail: editLessonThumbnail.trim() || null,
+ teaching_text: editLessonTeachingText.trim() || null,
+ teaching_image_url: editLessonTeachingImageUrl.trim() || null,
+ teaching_audio_url: editLessonTeachingAudioUrl.trim() || null,
+ teaching_video_url: editLessonTeachingVideoUrl.trim() || null,
+ display_order: Math.max(0, Number(editLessonDisplayOrder) || 0),
+ is_active: editLessonIsActive,
})
- // Per backend guide, use embed_url as the video_url reference.
- const embedUrl = uploadRes.data?.data?.embed_url?.trim()
- const vimeoUrl = uploadRes.data?.data?.url?.trim()
- if (!embedUrl) throw new Error("Missing uploaded video embed_url")
-
- // Backend requires: https://player.vimeo.com/video/?h=
- // where is the last path segment from `url` (e.g. https://vimeo.com//)
- const hashFromUrl = vimeoUrl ? vimeoUrl.split("/").filter(Boolean).at(-1) : undefined
- const finalVideoUrl = hashFromUrl ? `${embedUrl}?h=${hashFromUrl}` : embedUrl
-
- const finalTitle = videoTitle.trim() || videoFile.name
-
- await createCourseVideo({
- sub_module_id: Number(subModuleId),
- title: finalTitle,
- description: videoDescription.trim(),
- video_url: finalVideoUrl,
- duration: videoDuration,
- resolution: videoResolution.trim() || undefined,
- visibility: videoVisibility,
- display_order: Number.isFinite(videoDisplayOrder) ? videoDisplayOrder : undefined,
- status: videoStatus,
- })
- setShowAddVideoModal(false)
- setVideoTitle("")
- setVideoDescription("")
- setVideoUrl("")
- setVideoFile(null)
- setVideoFileSize(0)
- setVideoDuration(0)
- setVideoResolution("1080p")
- setVideoVisibility("PUBLISHED")
- setVideoStatus("PUBLISHED")
- setVideoDisplayOrder(1)
- await fetchVideos()
- } catch (err) {
- console.error("Failed to create video:", err)
- setSaveError("Failed to create video")
+ setLessonDetail(response.data?.data ?? null)
+ setLessonEditMode(false)
+ toast.success("Lesson updated")
+ await fetchLessons()
+ } catch (error) {
+ console.error(error)
+ toast.error("Failed to update lesson")
} finally {
- setSaving(false)
+ setLessonUpdateSaving(false)
}
}
- const handleEditVideoClick = (video: SubCourseVideo) => {
- setVideoToEdit(video)
- setVideoTitle(video.title)
- setVideoDescription(video.description || "")
- setVideoUrl(video.video_url || "")
- setSaveError(null)
- setShowEditVideoModal(true)
- }
+ const handleSoftDeleteLesson = async () => {
+ if (!lessonDetail || lessonSoftDeleteSaving) return
- const handleSaveEditVideo = async () => {
- if (!videoToEdit) return
- setSaving(true)
- setSaveError(null)
+ setLessonSoftDeleteSaving(true)
try {
- await updateSubCourseVideo(videoToEdit.id, {
- title: videoTitle,
- description: videoDescription,
- video_url: videoUrl,
- })
- setShowEditVideoModal(false)
- setVideoToEdit(null)
- setVideoTitle("")
- setVideoDescription("")
- setVideoUrl("")
- await fetchVideos()
- } catch (err) {
- console.error("Failed to update video:", err)
- setSaveError("Failed to update video")
+ const response = await softDeleteSubModuleLesson(lessonDetail.id)
+ setLessonDetail(response.data?.data ?? { ...lessonDetail, is_active: false })
+ setLessonEditMode(false)
+ setLessonSoftDeleteConfirmOpen(false)
+ toast.success("Lesson soft deleted")
+ await fetchLessons()
+ } catch (error) {
+ console.error(error)
+ toast.error("Failed to soft delete lesson")
} finally {
- setSaving(false)
+ setLessonSoftDeleteSaving(false)
}
}
- const handleDeleteVideoClick = (video: SubCourseVideo) => {
- setVideoToDelete(video)
- setShowDeleteVideoModal(true)
+ useEffect(() => {
+ void fetchSets()
+ }, [fetchSets])
+
+ useEffect(() => {
+ if (activeTab === "lessons") void fetchLessons()
+ }, [activeTab, fetchLessons])
+
+ useEffect(() => {
+ if (activeTab === "videos") void fetchVideos()
+ }, [activeTab, fetchVideos])
+
+ const { practices, capstones } = useMemo(() => partitionSets(sets), [sets])
+
+ const filteredPractices = useMemo(
+ () => filterQuestionSets(practices, practiceSearch, practiceStatusFilter),
+ [practices, practiceSearch, practiceStatusFilter],
+ )
+ const filteredCapstones = useMemo(
+ () => filterQuestionSets(capstones, capstoneSearch, capstoneStatusFilter),
+ [capstones, capstoneSearch, capstoneStatusFilter],
+ )
+ const filteredLessons = useMemo(
+ () => filterLessons(lessons, lessonSearch, lessonActiveFilter),
+ [lessons, lessonSearch, lessonActiveFilter],
+ )
+ const subModuleLabel = useMemo(() => {
+ const rawTitle = subCourse?.title?.trim()
+ if (!rawTitle) return "Sub-module"
+
+ const cleaned = rawTitle.replace(/^module\s*[-:]?\s*/i, "")
+ const numericMatch = cleaned.match(/\d+(?:\.\d+)+|\d+/)
+ if (!numericMatch) return "Sub-module"
+
+ return `Sub-module ${numericMatch[0]}`
+ }, [subCourse?.title])
+
+ const basePath = isHumanLanguageRoute
+ ? `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}`
+ : `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`
+
+ const courseStructurePath = `/content/category/${categoryId}/courses/${courseId}/sub-modules`
+ const backHref = isHumanLanguageRoute ? "/content/human-language" : courseStructurePath
+ const backLabel = isHumanLanguageRoute ? "Back to Human Language" : "Back to course structure"
+
+ const goQuestions = (questionSetId: number) => {
+ navigate(`${basePath}/practices/${questionSetId}/questions`)
}
- const handleConfirmDeleteVideo = async () => {
- if (!videoToDelete) return
- setDeletingVideo(true)
- try {
- await deleteSubCourseVideo(videoToDelete.id)
- setShowDeleteVideoModal(false)
- setVideoToDelete(null)
- await fetchVideos()
- } catch (err) {
- console.error("Failed to delete video:", err)
- } finally {
- setDeletingVideo(false)
+ const questionSetStatusBadge = (status: string) => {
+ const cfg: Record = {
+ PUBLISHED: { cls: "border-emerald-200 bg-emerald-50 text-emerald-700", label: "Published" },
+ DRAFT: { cls: "border-grayScale-200 bg-grayScale-100 text-grayScale-600", label: "Draft" },
+ ARCHIVED: { cls: "border-amber-200 bg-amber-50 text-amber-800", label: "Archived" },
}
- }
-
- // Preview a video card.
- // We prefer embedding directly from `video_url` because Vimeo embeds may require the `h=` hash.
- const handlePreviewVideo = async (video: SubCourseVideo) => {
- setShowPreviewModal(true)
- setPreviewLoading(true)
- setPreviewIframe("")
- setPreviewVideo(null)
- try {
- const directUrl = video.video_url?.trim()
- if (directUrl) {
- setPreviewIframe(
- ``,
- )
- setPreviewVideo(null)
- return
- }
-
- // Fallback to sample API when a direct URL is unavailable.
- const idMatch = video.video_url?.match(/(\d{5,})/)
- const vimeoId = idMatch?.[1] ?? "76979871" // fallback to Big Buck Bunny
- const res = await getVimeoSample(vimeoId)
- setPreviewIframe(res.data.data.iframe)
- setPreviewVideo(res.data.data.video)
- } catch {
- setPreviewIframe("")
- } finally {
- setPreviewLoading(false)
- }
- }
-
- const filteredPractices = practices.filter((practice) => {
- if (statusFilter === "all") return true
- if (statusFilter === "published") return practice.status === "PUBLISHED"
- if (statusFilter === "draft") return practice.status === "DRAFT"
- if (statusFilter === "archived") return practice.status === "ARCHIVED"
- return true
- })
-
- if (loading) {
+ const c = cfg[status] ?? cfg.DRAFT
return (
-
-
-
Loading course…
+
+ {c.label}
+
+ )
+ }
+
+ const setTypeBadge = (set: QuestionSet) => (
+
+ {normalizeSetType(set)}
+
+ )
+
+ const renderQuestionSetTable = (
+ filtered: QuestionSet[],
+ source: QuestionSet[],
+ emptyLabel: string,
+ addHref: string,
+ addLabel: string,
+ noMatchLabel: string,
+ ) => {
+ const colCount = 8
+
+ if (setsLoading) {
+ return (
+
+
+
+
+ TITLE
+ TYPE
+ STATUS
+ DESCRIPTION
+ PERSONA
+ SHUFFLE
+ CREATED
+ DETAILS
+
+
+
+
+
+
+
+ Loading question sets…
+
+
+
+
+
+
+ )
+ }
+
+ if (source.length === 0) {
+ return (
+
+
{emptyLabel}
+
+ {addLabel}
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ TITLE
+ TYPE
+ STATUS
+ DESCRIPTION
+ PERSONA
+ SHUFFLE
+ CREATED
+ DETAILS
+
+
+
+ {filtered.length === 0 ? (
+
+
+
+
+
+
{noMatchLabel}
+
Try adjusting search or status.
+
+
+
+
+ ) : (
+ filtered.map((item) => (
+ goQuestions(item.id)}
+ onKeyDown={(ev) => {
+ if (ev.key === "Enter" || ev.key === " ") {
+ ev.preventDefault()
+ goQuestions(item.id)
+ }
+ }}
+ >
+
+
+
+
+
+
+
{item.title}
+
#{item.id}
+
+
+
+ {setTypeBadge(item)}
+ {questionSetStatusBadge(item.status)}
+
+
+ {item.description?.trim() ? item.description : "—"}
+
+
+
+
+ {item.persona?.trim() ? item.persona : "—"}
+
+
+
+ {item.shuffle_questions ? "Yes" : "No"}
+
+
+
+
{formatTableDate(item.created_at)}
+
{getRelativeTime(item.created_at)}
+
+
+
+ {
+ e.stopPropagation()
+ goQuestions(item.id)
+ }}
+ aria-label="Open questions"
+ >
+
+
+
+
+ ))
+ )}
+
+
)
}
- if (error) {
+ const renderLessonsTable = (filtered: SubModuleLesson[], source: SubModuleLesson[]) => {
+ const colCount = 7
+ const sorted = filtered.slice().sort((a, b) => a.display_order - b.display_order || a.id - b.id)
+
+ if (lessonsLoading) {
+ return (
+
+
+
+
+ LESSON
+ ORDER
+ STATUS
+ DESCRIPTION
+ THUMBNAIL
+ CREATED
+ DETAILS
+
+
+
+
+
+
+
+ Loading lessons…
+
+
+
+
+
+
+ )
+ }
+
+ if (source.length === 0) {
+ return (
+
+
No lessons yet
+
+ Create a lesson
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ LESSON
+ ORDER
+ STATUS
+ DESCRIPTION
+ THUMBNAIL
+ CREATED
+ DETAILS
+
+
+
+ {sorted.length === 0 ? (
+
+
+
+
+
+
+ No lessons match your search or status filter.
+
+
Try adjusting search or status.
+
+
+
+
+ ) : (
+ sorted.map((lesson) => (
+ void openLessonDetail(lesson.id)}
+ onKeyDown={(ev) => {
+ if (ev.key === "Enter" || ev.key === " ") {
+ ev.preventDefault()
+ void openLessonDetail(lesson.id)
+ }
+ }}
+ >
+
+
+
+
+
+
+
{lesson.title}
+
#{lesson.id}
+
+
+
+
+ {lesson.display_order}
+
+ {lessonStatusBadge(lesson.is_active)}
+
+
+ {lesson.description?.trim() ? lesson.description : "—"}
+
+
+
+ {lesson.thumbnail?.trim() ? (
+ Yes
+ ) : (
+ None
+ )}
+
+
+
+
{formatTableDate(lesson.created_at)}
+
{getRelativeTime(lesson.created_at)}
+
+
+
+ {
+ e.stopPropagation()
+ void openLessonDetail(lesson.id)
+ }}
+ aria-label="Lesson detail"
+ >
+
+
+
+
+ ))
+ )}
+
+
+
+ )
+ }
+
+ if (loadingMeta) {
return (
-
-
{error}
+
+
Loading sub-module…
+
+ )
+ }
+
+ if (metaError && !subCourse) {
+ return (
+
+
+
+ {backLabel}
+
+
+ {metaError}
+
+ Return
+
+
)
}
return (
-
- {/* Back Button */}
+
- Back to Human Language
+ {backLabel}
- {/* SubCourse Header */}
-
-
-
-
- {subCourse?.title}
-
- {subCourse?.level && (
-
{subCourse.level}
- )}
+
+
+
+
{subModuleLabel}
+ {subCourse?.cefr_level || subCourse?.level ? (
+ {subCourse.cefr_level ?? subCourse.level}
+ ) : null}
-
- {subCourse?.description || "No description available"}
+
+ Practices come from question sets (`PRACTICE`) and intro videos are listed separately as
+ sub-module-level media.
-
-
-
- Add Practice
+
+
+
+
+ Add practice
+
-
-
- Add lesson video
+
+
+
+ Add lesson
+
-
- Use Lesson for videos/audio and{" "}
- Practice for question sets tied to this sub-module.
-
-
- {/* Tabs */}
-
-
setActiveTab("lesson")}
- className={`relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all ${
- activeTab === "lesson"
- ? "text-brand-600"
- : "text-grayScale-400 hover:text-grayScale-700"
- }`}
- >
- Lesson
- {activeTab === "lesson" && (
-
- )}
-
-
setActiveTab("practice")}
- className={`relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all ${
- activeTab === "practice"
- ? "text-brand-600"
- : "text-grayScale-400 hover:text-grayScale-700"
- }`}
- >
- Practice
- {activeTab === "practice" && (
-
- )}
-
+
+ {(
+ [
+ ["practices", "Practices", FileText],
+ ["lessons", "Lessons", BookOpen],
+ ["capstones", "Capstones", Trophy],
+ ["videos", "Intro videos", Video],
+ ] as const
+ ).map(([id, label, Icon]) => (
+ setActiveTab(id)}
+ className={`relative flex items-center gap-2 px-1 pb-3 pt-1 text-sm font-semibold transition-colors ${
+ activeTab === id ? "text-brand-600" : "text-grayScale-400 hover:text-grayScale-700"
+ }`}
+ >
+
+ {label}
+ {activeTab === id ? (
+
+ ) : null}
+
+ ))}
-
-
- {/* Content */}
- {activeTab === "practice" && (
- <>
- {practicesLoading ? (
-
- ) : filteredPractices.length === 0 ? (
-
-
-
-
-
No practices yet
-
Create your first practice to get started
-
-
- Add Practice
-
-
- ) : (
-
- {filteredPractices.map((practice) => {
- const statusConfig: Record
= {
- PUBLISHED: { bg: "bg-green-50 text-green-700 ring-1 ring-inset ring-green-200", dot: "bg-green-500", text: "Published" },
- DRAFT: { bg: "bg-grayScale-50 text-grayScale-600 ring-1 ring-inset ring-grayScale-200", dot: "bg-grayScale-400", text: "Draft" },
- ARCHIVED: { bg: "bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200", dot: "bg-amber-500", text: "Archived" },
- }
- const status = statusConfig[practice.status] ?? statusConfig.DRAFT
-
- return (
- handlePracticeClick(practice.id)}
- >
-
-
-
{practice.title}
-
-
- {status.text}
-
-
-
-
{practice.description}
-
-
-
- {practice.set_type}
-
- {practice.persona && (
-
- {practice.persona}
-
- )}
-
-
-
-
-
- {practice.owner_type.replace("_", " ")}
-
- {practice.shuffle_questions && (
-
Shuffle ON
- )}
-
-
-
-
- {new Date(practice.created_at).toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- year: "numeric",
- })}
-
-
e.stopPropagation()}>
- handleEditClick(practice)}
- className="rounded-lg p-1.5 text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-700"
- >
-
-
- handleDeleteClick(practice)}
- className="rounded-lg p-1.5 text-grayScale-400 transition-colors hover:bg-red-50 hover:text-red-500"
- >
-
-
-
-
-
-
- )
- })}
+ {activeTab === "practices" ? (
+
+
+
+
+ setPracticeSearch(e.target.value)}
+ aria-label="Search practices"
+ />
+
setPracticeStatusFilter(e.target.value as "all" | QuestionSetStatus)}
+ aria-label="Filter practices by status"
+ >
+ All statuses
+ Published
+ Draft
+ Archived
+
+
+ {renderQuestionSetTable(
+ filteredPractices,
+ practices,
+ "No practices yet",
+ `${basePath}/add-practice`,
+ "Create a practice",
+ "No practices match your search or status filter.",
)}
- >
- )}
+
+ ) : null}
- {activeTab === "lesson" && (
- <>
- {videosLoading ? (
-
-
-
Loading videos…
+ {activeTab === "lessons" ? (
+
+
+
+
+ setLessonSearch(e.target.value)}
+ aria-label="Search lessons"
+ />
- ) : videos.length === 0 ? (
-
-
-
-
-
No videos yet
-
Upload your first video to get started
-
-
- Add Video
-
+
setLessonActiveFilter(e.target.value as LessonActiveFilter)}
+ aria-label="Filter lessons by status"
+ >
+ All
+ Active only
+ Inactive only
+
+
+ {renderLessonsTable(filteredLessons, lessons)}
+
+ ) : null}
+
+ {activeTab === "capstones" ? (
+
+
+
+
+ setCapstoneSearch(e.target.value)}
+ aria-label="Search capstones"
+ />
- ) : (
-
- {videos.map((video, index) => {
- const gradients = [
- "bg-gradient-to-br from-blue-100 via-blue-50 to-indigo-100",
- "bg-gradient-to-br from-amber-100 via-yellow-50 to-orange-100",
- "bg-gradient-to-br from-purple-100 via-fuchsia-50 to-pink-100",
- "bg-gradient-to-br from-emerald-100 via-green-50 to-teal-100",
- ]
- const formatDuration = (seconds: number) => {
- const mins = Math.floor(seconds / 60)
- const secs = seconds % 60
- return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
- }
- return (
-
- {/* Thumbnail with duration */}
-
- {video.thumbnail ? (
-
- ) : (
-
-
-
- )}
-
- {formatDuration(video.duration || 0)}
-
+
setCapstoneStatusFilter(e.target.value as "all" | QuestionSetStatus)}
+ aria-label="Filter capstones by status"
+ >
+ All statuses
+ Published
+ Draft
+ Archived
+
+
+ {renderQuestionSetTable(
+ filteredCapstones,
+ capstones,
+ "No capstone-style sets yet (e.g. EXAM). Add content via your usual authoring flow or API.",
+ `${basePath}/add-practice`,
+ "Add question set (practice flow)",
+ "No capstones match your search or status filter.",
+ )}
+
+ ) : null}
+
+ {activeTab === "videos" ? (
+ videosLoading ? (
+
+
+
+
+ TITLE
+ DESCRIPTION
+ DURATION
+ PUBLISHED
+ ACTIVE
+ THUMBNAIL
+ LINK
+
+
+
+
+
+
+
+ Loading videos…
-
- {/* Content */}
-
- {/* Status and menu */}
-
-
-
- {video.is_published ? "PUBLISHED" : "DRAFT"}
-
-
-
setOpenVideoMenuId(openVideoMenuId === video.id ? null : video.id)}
- className="rounded-lg p-1.5 text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
- >
-
-
- {openVideoMenuId === video.id && (
-
- {
- handleDeleteVideoClick(video)
- setOpenVideoMenuId(null)
- }}
- className="flex w-full items-center gap-2 px-4 py-2 text-sm font-medium text-red-500 transition-colors hover:bg-red-50"
- >
-
- Delete
-
-
- )}
+
+
+
+
+
+ ) : videos.length === 0 ? (
+
+ No intro videos on this sub-module yet.
+
+ ) : (
+
+
+
+
+ TITLE
+ DESCRIPTION
+ DURATION
+ PUBLISHED
+ ACTIVE
+ THUMBNAIL
+ LINK
+
+
+
+ {videos.map((v) => (
+
+
+
-
- {/* Title */}
- {video.title}
-
- {/* Edit / Preview buttons */}
-
-
handleEditVideoClick(video)}
- >
-
- Edit
-
-
handlePreviewVideo(video)}
- >
-
- Preview
-
-
-
- {/* Publish button */}
-
+
+
+ {v.description?.trim() ? v.description : "—"}
+
+
+
+ {formatVideoDuration(v.duration)}
+
+
+
- {video.is_published ? "Published" : "Publish"}
-
-
-
- )
- })}
-
- )}
- >
- )}
-
- {/* Delete Modal */}
- {showDeleteModal && practiceToDelete && (
-
-
-
-
Delete Practice
- setShowDeleteModal(false)}
- className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
- >
-
-
-
-
-
- Are you sure you want to delete{" "}
- {practiceToDelete.title} ? This action cannot be undone.
-
-
-
- setShowDeleteModal(false)} disabled={deleting}>
- Cancel
-
-
- {deleting ? "Deleting..." : "Delete"}
-
-
+ {v.is_published ? "Yes" : "No"}
+
+
+
+
+ {v.is_active ? "Yes" : "No"}
+
+
+
+ {v.thumbnail?.trim() ? (
+ Yes
+ ) : (
+ None
+ )}
+
+
+ {v.video_url ? (
+
+
+
+ Open
+
+
+ ) : (
+ —
+ )}
+
+
+ ))}
+
+
-
- )}
+ )
+ ) : null}
- {/* Edit Practice Modal */}
- {showEditPracticeModal && practiceToEdit && (
-
-
-
-
Edit Practice
- setShowEditPracticeModal(false)}
- className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
- >
-
-
-
-
-
- Title
- setTitle(e.target.value)}
- placeholder="Enter practice title"
- />
-
-
- Description
-
-
- Persona (Optional)
- setPersona(e.target.value)}
- placeholder="Enter persona"
- />
-
- {saveError &&
{saveError}
}
-
-
- setShowEditPracticeModal(false)} disabled={saving}>
- Cancel
-
-
- {saving ? "Saving..." : "Save"}
-
-
-
-
- )}
+
{
+ if (lessonUpdateSaving || lessonSoftDeleteSaving) return
+ setLessonDetailOpen(open)
+ if (!open) {
+ setLessonDetail(null)
+ setLessonEditMode(false)
+ setLessonSoftDeleteConfirmOpen(false)
+ }
+ }}
+ >
+
+
+ Lesson detail
+
+ Loaded from `GET /course-management/sub-module-lessons/:lessonId`.
+
+
- {/* Add Video Modal */}
- {showAddVideoModal && (
-
-
-
-
Add Video
-
{ setShowAddVideoModal(false); setVideoFile(null) }}
- className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
- >
-
-
+ {lessonDetailLoading ? (
+
+
+
Loading lesson detail…
-
-
- Title
- setVideoTitle(e.target.value)}
- placeholder="Enter video title"
- />
-
-
- Description
-
-
-
Video File
-
handleVideoFileSelect(e.target.files?.[0] ?? null)}
- />
- {videoFile && (
-
- Selected: {videoFile.name}
-
- )}
-
-
-
-
-
- Visibility
- setVideoVisibility(e.target.value)}
- className="h-11 w-full rounded-lg border border-grayScale-200 bg-white px-3 text-sm text-grayScale-700 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
- >
- PUBLISHED
- DRAFT
- PRIVATE
- UNLISTED
-
-
-
- Status
- setVideoStatus(e.target.value)}
- className="h-11 w-full rounded-lg border border-grayScale-200 bg-white px-3 text-sm text-grayScale-700 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
- >
- PUBLISHED
- DRAFT
- ARCHIVED
-
-
-
- {saveError &&
{saveError}
}
+ ) : !lessonDetail ? (
+
+ Lesson detail unavailable.
-
- setShowAddVideoModal(false)} disabled={saving}>
- Cancel
-
-
- {saving ? "Uploading..." : "Upload Video"}
-
-
-
-
- )}
-
- {/* Edit Video Modal */}
- {showEditVideoModal && videoToEdit && (
-
-
-
-
Edit Video
- setShowEditVideoModal(false)}
- className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
- >
-
-
-
-
-
- Title
- setVideoTitle(e.target.value)}
- placeholder="Enter video title"
- />
-
-
- Description
-
-
- Video URL
- setVideoUrl(e.target.value)}
- placeholder="Enter video URL"
- />
-
- {saveError &&
{saveError}
}
-
-
- setShowEditVideoModal(false)} disabled={saving}>
- Cancel
-
-
- {saving ? "Saving..." : "Save"}
-
-
-
-
- )}
-
- {/* Delete Video Modal */}
- {showDeleteVideoModal && videoToDelete && (
-
-
-
-
Delete Video
- setShowDeleteVideoModal(false)}
- className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
- >
-
-
-
-
-
- Are you sure you want to delete{" "}
- {videoToDelete.title} ? This action cannot be undone.
-
-
-
- setShowDeleteVideoModal(false)} disabled={deletingVideo}>
- Cancel
-
-
- {deletingVideo ? "Deleting..." : "Delete"}
-
-
-
-
- )}
-
- {/* Video Preview Modal */}
- {showPreviewModal && (
-
-
-
+ ) : lessonEditMode ? (
+
-
- {previewVideo?.name ?? "Video Preview"}
-
- {previewVideo && (
-
- {Math.floor(previewVideo.duration / 60)}:{(previewVideo.duration % 60).toString().padStart(2, "0")} • {previewVideo.width}×{previewVideo.height}
-
- )}
+
Title
+
setEditLessonTitle(event.target.value)}
+ placeholder="Lesson title"
+ disabled={lessonUpdateSaving}
+ />
-
setShowPreviewModal(false)}
- className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
- >
-
-
-
-
- {previewLoading ? (
-
-
+
+
+ Description
+
+
+
+ Thumbnail URL
+ setEditLessonThumbnail(event.target.value)}
+ placeholder="https://cdn.example.com/thumb.jpg"
+ disabled={lessonUpdateSaving}
+ />
+
+
+
+ Teaching text
+
+
+
+
+
+ Teaching video URL
+ setEditLessonTeachingVideoUrl(event.target.value)}
+ placeholder="https://cdn.example.com/lesson.mp4"
+ disabled={lessonUpdateSaving}
+ />
+
+
+
+
+
+ setLessonEditMode(false)}
+ >
+ Cancel
+
+ void handleUpdateLesson()}
+ >
+ {lessonUpdateSaving ? "Saving..." : "Save changes"}
+
+
+
+ ) : (
+
+
+
+ Edit lesson
+
+ setLessonSoftDeleteConfirmOpen(true)}
+ >
+ {lessonSoftDeleteSaving ? "Deleting..." : "Soft delete"}
+
+
+
+ {lessonDetail.thumbnail ? (
+
) : (
-
-
Failed to load preview.
+
+ Default thumbnail
)}
+
+
+
+
{lessonDetail.title}
+ {lessonStatusBadge(lessonDetail.is_active)}
+
+
{new Date(lessonDetail.created_at).toLocaleString()}
+
+
+ {lessonDetail.description ? (
+
+ {lessonDetail.description}
+
+ ) : null}
+
+ {lessonDetail.teaching_text ? (
+
+
Teaching text
+
+ {lessonDetail.teaching_text}
+
+
+ ) : null}
+
+
+
+ {lessonDetail.teaching_video_url ? (
+
+ ) : null}
-
-
- )}
+ )}
+
+
+
+
{
+ if (lessonSoftDeleteSaving) return
+ setLessonSoftDeleteConfirmOpen(open)
+ }}
+ >
+
+
+ Soft delete lesson?
+
+ This will deactivate {lessonDetail?.title ?? "this lesson"} .
+ You can reactivate it later by setting status back to active.
+
+
+
+ setLessonSoftDeleteConfirmOpen(false)}
+ >
+ Cancel
+
+ void handleSoftDeleteLesson()}
+ >
+ {lessonSoftDeleteSaving ? "Deactivating..." : "Confirm soft delete"}
+
+
+
+
)
}
diff --git a/src/pages/content-management/PracticeQuestionsPage.tsx b/src/pages/content-management/PracticeQuestionsPage.tsx
index 5de5680..b704200 100644
--- a/src/pages/content-management/PracticeQuestionsPage.tsx
+++ b/src/pages/content-management/PracticeQuestionsPage.tsx
@@ -58,7 +58,13 @@ const typeColors: Record
= {
}
export function PracticeQuestionsPage() {
- const { categoryId, courseId, subModuleId, practiceId } = useParams()
+ const { categoryId, courseId, subModuleId, levelId, practiceId } = useParams<{
+ categoryId: string
+ courseId: string
+ subModuleId?: string
+ levelId?: string
+ practiceId?: string
+ }>()
const location = useLocation()
const [questions, setQuestions] = useState([])
@@ -102,11 +108,14 @@ export function PracticeQuestionsPage() {
const [saveError, setSaveError] = useState(null)
const backLink = useMemo(() => {
- if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/")) {
+ if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/level/") && levelId) {
+ return "/content/human-language"
+ }
+ if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/") && subModuleId) {
return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}`
}
return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`
- }, [location.pathname, categoryId, courseId, subModuleId])
+ }, [location.pathname, categoryId, courseId, subModuleId, levelId])
const buildDefaultOptions = (type: QuestionType, sampleAnswerText?: string): DraftOption[] => {
if (type === "TRUE_FALSE") {
diff --git a/src/pages/content-management/SpeakingPage.tsx b/src/pages/content-management/SpeakingPage.tsx
index 3bc4d87..94ea96f 100644
--- a/src/pages/content-management/SpeakingPage.tsx
+++ b/src/pages/content-management/SpeakingPage.tsx
@@ -1,5 +1,5 @@
import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
-import { ArrowLeft, ChevronDown, ChevronRight, Image as ImageIcon, Loader2, Mic, Plus, Trash2, Upload } from "lucide-react"
+import { ArrowLeft, ChevronDown, ChevronRight, Image as ImageIcon, Mic, Plus, Trash2, Upload } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
@@ -1926,7 +1926,7 @@ export function SpeakingPage() {
className="gap-1.5"
>
{uploadingIntroVideo ? (
-
+
) : (
)}
diff --git a/src/pages/content-management/SubCategoryCoursesPage.tsx b/src/pages/content-management/SubCategoryCoursesPage.tsx
new file mode 100644
index 0000000..f6ee24e
--- /dev/null
+++ b/src/pages/content-management/SubCategoryCoursesPage.tsx
@@ -0,0 +1,144 @@
+import { useEffect, useState } from "react"
+import { Link, useNavigate, useParams } from "react-router-dom"
+import { ArrowLeft, BookOpen, ChevronRight } from "lucide-react"
+import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
+import alertSrc from "../../assets/Alert.svg"
+import { Badge } from "../../components/ui/badge"
+import { getCoursesBySubCategoryId, getSubCategoriesByCategoryId } from "../../api/courses.api"
+import type { CategorySubCategoryListItem, SubCategoryCourseListItem } from "../../types/course.types"
+import { cn } from "../../lib/utils"
+
+export function SubCategoryCoursesPage() {
+ const { categoryId, subCategoryId } = useParams<{
+ categoryId: string
+ subCategoryId: string
+ }>()
+ const navigate = useNavigate()
+
+ const [subCategory, setSubCategory] = useState(null)
+ const [courses, setCourses] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ const run = async () => {
+ if (!categoryId || !subCategoryId) return
+ const cid = Number(categoryId)
+ const sid = Number(subCategoryId)
+ if (!Number.isFinite(cid) || !Number.isFinite(sid)) {
+ setError("Invalid route parameters")
+ setLoading(false)
+ return
+ }
+
+ setLoading(true)
+ setError(null)
+ try {
+ const [subRes, coursesRes] = await Promise.all([
+ getSubCategoriesByCategoryId(cid),
+ getCoursesBySubCategoryId(sid),
+ ])
+ const list = subRes.data?.data?.sub_categories ?? []
+ const found = Array.isArray(list) ? list.find((s) => s.id === sid) : undefined
+ setSubCategory(found ?? null)
+
+ const raw = coursesRes.data?.data?.courses
+ setCourses(Array.isArray(raw) ? raw : [])
+ } catch (e) {
+ console.error(e)
+ setError("Failed to load courses for this sub-category")
+ setCourses([])
+ } finally {
+ setLoading(false)
+ }
+ }
+ void run()
+ }, [categoryId, subCategoryId])
+
+ if (loading) {
+ return (
+
+
+
Loading courses…
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+
+
{error}
+
+
+ )
+ }
+
+ const label = subCategory?.name ?? "Sub-category"
+
+ return (
+
+
+
+
+
+
+
+
+
Sub-category
+
{label}
+
+ {courses.length} course{courses.length !== 1 ? "s" : ""} — open a course to manage sub-modules
+
+
+
+
+
+
+ {courses.length === 0 ? (
+
+
No courses in this sub-category yet
+
Add a course from your authoring flow or API.
+
+ ) : (
+
+ {courses.map((c) => (
+
+ navigate(`/content/category/${categoryId}/courses/${c.id}/sub-modules`)
+ }
+ className={cn(
+ "flex w-full items-center justify-between gap-4 rounded-xl border border-grayScale-200 bg-white px-4 py-4 text-left shadow-sm transition-all",
+ "hover:border-brand-200 hover:bg-brand-50/40 hover:shadow-md",
+ )}
+ >
+
+
+
+
+
+
{c.title}
+ {c.description?.trim() ? (
+
{c.description}
+ ) : null}
+
+
+
+
+ {c.is_active ? "Active" : "Inactive"}
+
+
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/src/pages/content-management/SubCourseContentPage.tsx b/src/pages/content-management/SubCourseContentPage.tsx
index fd3024c..a888364 100644
--- a/src/pages/content-management/SubCourseContentPage.tsx
+++ b/src/pages/content-management/SubCourseContentPage.tsx
@@ -103,9 +103,10 @@ export function SubModuleContentPage() {
try {
const subCoursesRes = await getSubModulesByCourse(Number(courseId))
- const foundSubCourse = subCoursesRes.data.data.sub_courses?.find(
- (sc) => sc.id === Number(subModuleId)
- )
+ const list = subCoursesRes.data?.data?.sub_courses
+ const foundSubCourse = Array.isArray(list)
+ ? list.find((sc) => sc.id === Number(subModuleId))
+ : undefined
setSubCourse(foundSubCourse ?? null)
} catch (err) {
console.error("Failed to fetch course data:", err)
@@ -123,7 +124,9 @@ export function SubModuleContentPage() {
setPracticesLoading(true)
try {
const res = await getQuestionSetsByOwner("SUB_MODULE", Number(subModuleId))
- setPractices(res.data.data ?? [])
+ const raw = res.data?.data
+ const list = Array.isArray(raw) ? raw : (raw as { question_sets?: QuestionSet[] })?.question_sets ?? []
+ setPractices(Array.isArray(list) ? list : [])
} catch (err) {
console.error("Failed to fetch practices:", err)
} finally {
@@ -136,7 +139,8 @@ export function SubModuleContentPage() {
setVideosLoading(true)
try {
const res = await getVideosBySubModule(Number(subModuleId))
- setVideos(res.data.data.videos ?? [])
+ const vids = res.data?.data?.videos ?? []
+ setVideos(Array.isArray(vids) ? vids : [])
} catch (err) {
console.error("Failed to fetch videos:", err)
} finally {
@@ -154,7 +158,7 @@ export function SubModuleContentPage() {
limit: ratingsPageSize,
offset,
})
- setRatings(res.data.data ?? [])
+ setRatings(res.data?.data ?? [])
} catch (err) {
console.error("Failed to fetch ratings:", err)
} finally {
@@ -405,8 +409,8 @@ export function SubModuleContentPage() {
const idMatch = video.video_url?.match(/(\d{5,})/)
const vimeoId = idMatch?.[1] ?? "76979871" // fallback to Big Buck Bunny
const res = await getVimeoSample(vimeoId)
- setPreviewIframe(res.data.data.iframe)
- setPreviewVideo(res.data.data.video)
+ setPreviewIframe(res.data?.data?.iframe ?? "")
+ setPreviewVideo(res.data?.data?.video ?? null)
} catch {
setPreviewIframe("")
} finally {
@@ -414,7 +418,7 @@ export function SubModuleContentPage() {
}
}
- const filteredPractices = practices.filter((practice) => {
+ const filteredPractices = (Array.isArray(practices) ? practices : []).filter((practice) => {
if (statusFilter === "all") return true
if (statusFilter === "published") return practice.status === "PUBLISHED"
if (statusFilter === "draft") return practice.status === "DRAFT"
@@ -440,6 +444,19 @@ export function SubModuleContentPage() {
)
}
+ if (!subCourse) {
+ return (
+
+
+
Sub-module not found
+
It may have been removed or the link is invalid.
+
+ Back to sub-modules
+
+
+ )
+ }
+
return (
{/* Back Button */}
@@ -590,7 +607,7 @@ export function SubModuleContentPage() {
- {practice.owner_type.replace("_", " ")}
+ {String(practice.owner_type ?? "SUB_MODULE").replace(/_/g, " ")}
{practice.shuffle_questions && (
Shuffle ON
@@ -599,11 +616,13 @@ export function SubModuleContentPage() {
- {new Date(practice.created_at).toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- year: "numeric",
- })}
+ {practice.created_at
+ ? new Date(practice.created_at).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })
+ : "—"}
e.stopPropagation()}>
();
- const navigate = useNavigate();
- const [subCourses, setSubCourses] = useState([]);
- const [course, setCourse] = useState(null);
- const [category, setCategory] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
+ categoryId: string
+ courseId: string
+ }>()
+ const navigate = useNavigate()
- const [openMenuId, setOpenMenuId] = useState(null);
- const [togglingId, setTogglingId] = useState(null);
- const [showDeleteModal, setShowDeleteModal] = useState(false);
- const [subCourseToDelete, setSubCourseToDelete] = useState(
- null,
- );
- const [deleting, setDeleting] = useState(false);
- const menuRef = useRef(null);
+ const [course, setCourse] = useState(null)
+ const [category, setCategory] = useState(null)
+ const [levels, setLevels] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
- const [showAddModal, setShowAddModal] = useState(false);
- const [showEditModal, setShowEditModal] = useState(false);
- const [subCourseToEdit, setSubCourseToEdit] = useState(
- null,
- );
- const [title, setTitle] = useState("");
- const [description, setDescription] = useState("");
- const [level, setLevel] = useState("BEGINNER");
- const [subLevel, setSubLevel] = useState("");
- const [thumbnailUrl, setThumbnailUrl] = useState("");
- const [thumbnailFile, setThumbnailFile] = useState(null);
- const [displayOrder, setDisplayOrder] = useState("1");
- const [saving, setSaving] = useState(false);
- const [saveError, setSaveError] = useState(null);
+ const [expandedLevelIds, setExpandedLevelIds] = useState>(new Set())
+ const [modulesByLevelId, setModulesByLevelId] = useState>({})
+ const [loadingModulesLevelId, setLoadingModulesLevelId] = useState(null)
- // View mode
- const [viewMode, setViewMode] = useState<"grid" | "flow">("grid");
+ const [expandedModuleIds, setExpandedModuleIds] = useState>(new Set())
+ const [subModulesByModuleId, setSubModulesByModuleId] = useState<
+ Record
+ >({})
+ const [loadingSubModulesModuleId, setLoadingSubModulesModuleId] = useState(null)
- // All prerequisites map: subCourseId -> prerequisites[]
- const [allPrereqMap, setAllPrereqMap] = useState<
- Record
- >({});
- const [allPrereqLoading, setAllPrereqLoading] = useState(false);
-
- // Prerequisites state
- const [showPrereqModal, setShowPrereqModal] = useState(false);
- const [prereqSubCourse, setPrereqSubCourse] = useState(
- null,
- );
- const [prerequisites, setPrerequisites] = useState(
- [],
- );
- const [prereqLoading, setPrereqLoading] = useState(false);
- const [prereqAdding, setPrereqAdding] = useState(false);
- const [prereqRemoving, setPrereqRemoving] = useState(null);
- const [selectedPrereqId, setSelectedPrereqId] = useState(0);
+ const modulesFetchedForLevelRef = useRef>(new Set())
+ const subModulesFetchedForModuleRef = useRef>(new Set())
useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
- setOpenMenuId(null);
- }
- };
-
- if (openMenuId !== null) {
- document.addEventListener("mousedown", handleClickOutside);
- }
- return () => document.removeEventListener("mousedown", handleClickOutside);
- }, [openMenuId]);
-
- const fetchSubCourses = async () => {
- if (!courseId) return;
-
- try {
- const subCoursesRes = await getSubModulesByCourse(Number(courseId));
- setSubCourses(subCoursesRes.data.data.sub_courses ?? []);
- } catch (err) {
- console.error("Failed to fetch sub-modules:", err);
- }
- };
-
- const fetchAllPrerequisites = async (scs: SubCourse[]) => {
- if (scs.length === 0) return;
- setAllPrereqLoading(true);
- try {
- const results = await Promise.all(
- scs.map((sc) =>
- getSubModulePrerequisites(sc.id).then((res) => ({
- id: sc.id,
- data: res.data.data ?? [],
- })),
- ),
- );
- const map: Record = {};
- for (const r of results) {
- map[r.id] = r.data;
- }
- setAllPrereqMap(map);
- } catch (err) {
- console.error("Failed to fetch all prerequisites:", err);
- } finally {
- setAllPrereqLoading(false);
- }
- };
+ modulesFetchedForLevelRef.current.clear()
+ subModulesFetchedForModuleRef.current.clear()
+ setExpandedLevelIds(new Set())
+ setExpandedModuleIds(new Set())
+ setModulesByLevelId({})
+ setSubModulesByModuleId({})
+ }, [courseId])
useEffect(() => {
- const fetchData = async () => {
- if (!courseId || !categoryId) return;
-
+ const run = async () => {
+ if (!courseId || !categoryId) return
+ setLoading(true)
+ setError(null)
try {
- const [subCoursesRes, coursesRes, categoriesRes] = await Promise.all([
- getSubModulesByCourse(Number(courseId)),
+ const [levelsRes, coursesRes, categoriesRes] = await Promise.all([
+ getCourseLevelsForCourse(Number(courseId)),
getCoursesByCategory(Number(categoryId)),
getCourseCategories(),
- ]);
+ ])
- setSubCourses(subCoursesRes.data.data.sub_courses ?? []);
+ const rawLevels = levelsRes.data?.data?.levels
+ const list = Array.isArray(rawLevels) ? rawLevels : []
+ setLevels(
+ [...list].sort((a, b) => {
+ const o = (a.display_order ?? 0) - (b.display_order ?? 0)
+ if (o !== 0) return o
+ return String(a.cefr_level ?? "").localeCompare(String(b.cefr_level ?? ""))
+ }),
+ )
- const foundCourse = coursesRes.data.data.courses?.find(
- (c) => c.id === Number(courseId),
- );
- setCourse(foundCourse ?? null);
+ const foundCourse = coursesRes.data?.data?.courses?.find((c) => c.id === Number(courseId))
+ setCourse(foundCourse ?? null)
- const foundCategory = categoriesRes.data.data.categories?.find(
+ const foundCategory = categoriesRes.data?.data?.categories?.find(
(c) => c.id === Number(categoryId),
- );
- setCategory(foundCategory ?? null);
- } catch (err) {
- console.error("Failed to fetch sub-modules:", err);
- setError("Failed to load courses");
+ )
+ setCategory(foundCategory ?? null)
+ } catch (e) {
+ console.error(e)
+ setError("Failed to load course structure")
} finally {
- setLoading(false);
- }
- };
-
- fetchData();
- }, [courseId, categoryId]);
-
- useEffect(() => {
- if (subCourses.length > 0) {
- fetchAllPrerequisites(subCourses);
- }
- }, [subCourses]);
-
- const handleToggleStatus = async (subCourse: SubCourse) => {
- setTogglingId(subCourse.id);
- try {
- await updateSubModuleStatus(subCourse.id, {
- is_active: !subCourse.is_active,
- level: subCourse.level,
- title: subCourse.title,
- });
- await fetchSubCourses();
- } catch (err) {
- console.error("Failed to update sub-course status:", err);
- } finally {
- setTogglingId(null);
- }
- };
-
- const handleDeleteClick = (subCourse: SubCourse) => {
- setSubCourseToDelete(subCourse);
- setShowDeleteModal(true);
- };
-
- const handleConfirmDelete = async () => {
- if (!subCourseToDelete) return;
-
- setDeleting(true);
- try {
- await deleteSubModule(subCourseToDelete.id);
- setShowDeleteModal(false);
- setSubCourseToDelete(null);
- await fetchSubCourses();
- } catch (err) {
- console.error("Failed to delete sub-course:", err);
- } finally {
- setDeleting(false);
- }
- };
-
- const nextSubCourseDisplayOrder = () =>
- subCourses.length === 0
- ? 1
- : Math.max(0, ...subCourses.map((s) => s.display_order ?? 0)) + 1;
-
- const handleAddSubCourse = () => {
- setTitle("");
- setDescription("");
- setLevel("BEGINNER");
- setSubLevel("");
- setThumbnailUrl("");
- setThumbnailFile(null);
- setDisplayOrder(String(nextSubCourseDisplayOrder()));
- setSaveError(null);
- setShowAddModal(true);
- };
-
- const handleSaveNewSubCourse = async () => {
- if (!courseId) return;
- setSaving(true);
- setSaveError(null);
- try {
- let thumbnail = thumbnailUrl.trim();
- if (thumbnailFile) {
- const uploadRes = await uploadImageFile(thumbnailFile);
- const uploadedUrl = uploadRes.data?.data?.url?.trim();
- if (!uploadedUrl) throw new Error("Missing uploaded image url");
- thumbnail = uploadedUrl;
- }
-
- const parsedOrder = parseInt(displayOrder, 10);
- const display_order = Number.isFinite(parsedOrder) && parsedOrder >= 0
- ? parsedOrder
- : nextSubCourseDisplayOrder();
-
- await createSubModule({
- course_id: Number(courseId),
- title: title.trim(),
- description: description.trim(),
- thumbnail,
- display_order,
- level: level.trim() || "BEGINNER",
- sub_level: subLevel.trim(),
- });
- setShowAddModal(false);
- setTitle("");
- setDescription("");
- setLevel("BEGINNER");
- setSubLevel("");
- setThumbnailUrl("");
- setThumbnailFile(null);
- setDisplayOrder("1");
- await fetchSubCourses();
- toast.success("Course created successfully");
- } catch (err: unknown) {
- console.error("Failed to create sub-course:", err);
- const msg =
- (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
- "Failed to create course";
- setSaveError(msg);
- toast.error(msg);
- } finally {
- setSaving(false);
- }
- };
-
- const handleEditClick = (subCourse: SubCourse) => {
- setSubCourseToEdit(subCourse);
- setTitle(subCourse.title);
- setDescription(subCourse.description);
- setLevel(subCourse.level);
- setSaveError(null);
- setShowEditModal(true);
- };
-
- const handleSaveEditSubCourse = async () => {
- if (!subCourseToEdit) return;
- setSaving(true);
- setSaveError(null);
- try {
- await updateSubModule(subCourseToEdit.id, {
- title,
- description,
- level,
- });
- setShowEditModal(false);
- setSubCourseToEdit(null);
- setTitle("");
- setDescription("");
- setLevel("");
- await fetchSubCourses();
- } catch (err) {
- console.error("Failed to update sub-course:", err);
- setSaveError("Failed to update course");
- } finally {
- setSaving(false);
- }
- };
-
- const handleSubModuleClick = (subModuleId: number) => {
- navigate(
- `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`,
- );
- };
-
- const handlePrereqClick = async (subCourse: SubCourse) => {
- setPrereqSubCourse(subCourse);
- setShowPrereqModal(true);
- setPrereqLoading(true);
- setSelectedPrereqId(0);
- try {
- const res = await getSubModulePrerequisites(subCourse.id);
- setPrerequisites(res.data.data ?? []);
- } catch (err) {
- console.error("Failed to fetch prerequisites:", err);
- setPrerequisites([]);
- } finally {
- setPrereqLoading(false);
- }
- };
-
- const handleAddPrerequisite = async () => {
- if (!prereqSubCourse || !selectedPrereqId) return;
- setPrereqAdding(true);
- try {
- await addSubModulePrerequisite(prereqSubCourse.id, {
- prerequisite_sub_course_id: selectedPrereqId,
- });
- const res = await getSubModulePrerequisites(prereqSubCourse.id);
- setPrerequisites(res.data.data ?? []);
- setSelectedPrereqId(0);
- } catch (err) {
- console.error("Failed to add prerequisite:", err);
- } finally {
- setPrereqAdding(false);
- }
- };
-
- const handleRemovePrerequisite = async (prereqId: number) => {
- if (!prereqSubCourse) return;
- setPrereqRemoving(prereqId);
- try {
- await removeSubModulePrerequisite(prereqSubCourse.id, prereqId);
- const res = await getSubModulePrerequisites(prereqSubCourse.id);
- setPrerequisites(res.data.data ?? []);
- } catch (err) {
- console.error("Failed to remove prerequisite:", err);
- } finally {
- setPrereqRemoving(null);
- }
- };
-
- // Build flow layers using topological sort
- const flowLayers = (() => {
- if (subCourses.length === 0) return [];
-
- // Find sub-modules with no prerequisites (roots)
- const hasPrereqs = new Set();
- const isPrereqOf = new Map(); // prereqId -> [subCourseIds that depend on it]
-
- for (const sc of subCourses) {
- const prereqs = allPrereqMap[sc.id] ?? [];
- if (prereqs.length > 0) {
- hasPrereqs.add(sc.id);
- }
- for (const p of prereqs) {
- const dependents = isPrereqOf.get(p.prerequisite_sub_course_id) ?? [];
- dependents.push(sc.id);
- isPrereqOf.set(p.prerequisite_sub_course_id, dependents);
+ setLoading(false)
}
}
+ void run()
+ }, [courseId, categoryId])
- // BFS-based layering
- const layers: SubCourse[][] = [];
- const placed = new Set();
-
- // Layer 0: no prerequisites
- const roots = subCourses.filter((sc) => !hasPrereqs.has(sc.id));
- if (roots.length > 0) {
- layers.push(roots);
- roots.forEach((sc) => placed.add(sc.id));
+ const loadModulesForLevel = useCallback(async (levelId: number) => {
+ if (modulesFetchedForLevelRef.current.has(levelId)) return
+ modulesFetchedForLevelRef.current.add(levelId)
+ setLoadingModulesLevelId(levelId)
+ try {
+ const res = await getModulesByLevel(levelId)
+ const raw = res.data?.data?.modules
+ const modules = Array.isArray(raw) ? raw : []
+ const sorted = [...modules].sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0)) as ModuleRow[]
+ setModulesByLevelId((prev) => ({ ...prev, [levelId]: sorted }))
+ } catch (e) {
+ console.error(e)
+ setModulesByLevelId((prev) => ({ ...prev, [levelId]: [] }))
+ } finally {
+ setLoadingModulesLevelId(null)
}
+ }, [])
- // Subsequent layers: all prereqs already placed
- let maxIterations = subCourses.length;
- while (placed.size < subCourses.length && maxIterations-- > 0) {
- const nextLayer = subCourses.filter((sc) => {
- if (placed.has(sc.id)) return false;
- const prereqs = allPrereqMap[sc.id] ?? [];
- return prereqs.every((p) => placed.has(p.prerequisite_sub_course_id));
- });
- if (nextLayer.length === 0) {
- // Remaining have circular deps or missing prereqs — just add them
- const remaining = subCourses.filter((sc) => !placed.has(sc.id));
- if (remaining.length > 0) layers.push(remaining);
- break;
+ const loadSubModulesForModule = useCallback(async (moduleId: number) => {
+ if (subModulesFetchedForModuleRef.current.has(moduleId)) return
+ subModulesFetchedForModuleRef.current.add(moduleId)
+ setLoadingSubModulesModuleId(moduleId)
+ try {
+ const res = await getSubModulesByModuleId(moduleId)
+ const raw = res.data?.data?.sub_modules
+ const subs = Array.isArray(raw) ? raw : []
+ const sorted = [...subs].sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
+ setSubModulesByModuleId((prev) => ({ ...prev, [moduleId]: sorted }))
+ } catch (e) {
+ console.error(e)
+ setSubModulesByModuleId((prev) => ({ ...prev, [moduleId]: [] }))
+ } finally {
+ setLoadingSubModulesModuleId(null)
+ }
+ }, [])
+
+ const toggleLevel = (levelId: number) => {
+ setExpandedLevelIds((prev) => {
+ const next = new Set(prev)
+ if (next.has(levelId)) next.delete(levelId)
+ else {
+ next.add(levelId)
+ void loadModulesForLevel(levelId)
}
- layers.push(nextLayer);
- nextLayer.forEach((sc) => placed.add(sc.id));
- }
+ return next
+ })
+ }
- return layers;
- })();
+ const toggleModule = (moduleId: number) => {
+ setExpandedModuleIds((prev) => {
+ const next = new Set(prev)
+ if (next.has(moduleId)) next.delete(moduleId)
+ else {
+ next.add(moduleId)
+ void loadSubModulesForModule(moduleId)
+ }
+ return next
+ })
+ }
- const availablePrerequisites = subCourses.filter(
- (sc) =>
- prereqSubCourse &&
- sc.id !== prereqSubCourse.id &&
- !prerequisites.some((p) => p.prerequisite_sub_course_id === sc.id),
- );
+ const openSubModule = (subModuleId: number) => {
+ navigate(`/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`)
+ }
if (loading) {
return (
+
Loading course structure…
- );
+ )
}
if (error) {
@@ -456,650 +186,185 @@ export function SubModulesPage() {
{error}
- );
+ )
}
return (
-
- {/* Header */}
+
-
-
- {category?.name}
-
-
→
-
- {course?.title}
+
+ {category?.name ?? "Category"}
+ →
+
+ {course?.title ?? `Course #${courseId}`}
-
- Courses
-
-
- {subCourses.length} course{subCourses.length !== 1 ? "s" : ""}{" "}
- available
+
Course structure
+
+ Open a level, then a module, then choose a sub-module to manage practices, lessons, and capstones.
-
- {subCourses.length > 0 && (
-
- setViewMode("grid")}
- className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
- viewMode === "grid"
- ? "bg-brand-500 text-white shadow-sm"
- : "text-grayScale-500 hover:text-grayScale-700"
- }`}
- >
-
- Grid
-
- setViewMode("flow")}
- className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
- viewMode === "flow"
- ? "bg-brand-500 text-white shadow-sm"
- : "text-grayScale-500 hover:text-grayScale-700"
- }`}
- >
-
- Flow
-
-
- )}
-
- Add New Course
-
-
- {/* Sub-course grid or empty state */}
- {subCourses.length === 0 ? (
-
-
-
-
- No courses yet
-
-
- Get started by adding your first course to this sub-category
+ {levels.length === 0 ? (
+
+
+
+ No levels yet
+
+ This course has no CEFR levels. Add levels in the backend or learning-path tools, then refresh.
-
- Add your first course
-
) : (
-
- {subCourses.map((subCourse, index) => {
- const gradients = [
- "bg-gradient-to-br from-blue-100 to-blue-200",
- "bg-gradient-to-br from-purple-100 to-purple-200",
- "bg-gradient-to-br from-green-100 to-green-200",
- "bg-gradient-to-br from-yellow-100 to-yellow-200",
- ];
+
+ {levels.map((level) => {
+ const isLevelOpen = expandedLevelIds.has(level.id)
+ const modules = modulesByLevelId[level.id]
+ const loadingMods = loadingModulesLevelId === level.id
+
return (
handleSubModuleClick(subCourse.id)}
+ key={level.id}
+ className="overflow-hidden border border-grayScale-200/80 shadow-sm transition-shadow hover:shadow-md"
>
- {/* Thumbnail with level badge */}
-
- {subCourse.thumbnail ? (
-
+
toggleLevel(level.id)}
+ className="flex w-full items-center gap-3 border-b border-transparent bg-white px-4 py-3.5 text-left transition-colors hover:bg-grayScale-50/80"
+ >
+
+
+
+
+
+ {level.title || level.cefr_level}
+
+ {level.cefr_level}
+
+ {!level.is_active ? (
+
+ Inactive
+
+ ) : null}
+
+ {level.description ? (
+
{level.description}
+ ) : null}
+
+ {isLevelOpen ? (
+
) : (
-
+
)}
- {subCourse.level && (
-
- {subCourse.level}
-
- )}
-
+
- {/* Content */}
-
- {/* Status and menu */}
-
-
-
- {subCourse.is_active ? "Active" : "Inactive"}
-
-
e.stopPropagation()}
- >
-
- setOpenMenuId(
- openMenuId === subCourse.id ? null : subCourse.id,
- )
- }
- className="grid h-7 w-7 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
- >
-
-
- {openMenuId === subCourse.id && (
-
-
{
- handlePrereqClick(subCourse);
- setOpenMenuId(null);
- }}
- className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-grayScale-600 transition-colors hover:bg-grayScale-50"
- >
-
- Prerequisites
-
-
-
{
- handleToggleStatus(subCourse);
- setOpenMenuId(null);
- }}
- disabled={togglingId === subCourse.id}
- className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-grayScale-600 transition-colors hover:bg-grayScale-50 disabled:opacity-50"
- >
- {subCourse.is_active ? (
- <>
-
- Deactivate
- >
- ) : (
- <>
-
- Activate
- >
- )}
-
-
-
{
- handleDeleteClick(subCourse);
- setOpenMenuId(null);
- }}
- className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-red-500 transition-colors hover:bg-red-50"
- >
-
- Delete
-
-
- )}
-
-
+ {isLevelOpen ? (
+
+ {loadingMods ? (
+
+
+ Loading modules…
+
+ ) : !modules || modules.length === 0 ? (
+ No modules in this level.
+ ) : (
+ modules.map((mod) => {
+ const isModOpen = expandedModuleIds.has(mod.id)
+ const subs = subModulesByModuleId[mod.id]
+ const loadingSubs = loadingSubModulesModuleId === mod.id
- {/* Title */}
-
-
- {subCourse.title}
-
-
- {subCourse.description || "No description available"}
-
-
+ return (
+
+
toggleModule(mod.id)}
+ className="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm transition-colors hover:bg-grayScale-50"
+ >
+
+
+
{mod.title}
+
#{mod.id}
+ {mod.description ? (
+
{mod.description}
+ ) : null}
+
+ {isModOpen ? (
+
+ ) : (
+
+ )}
+
- {/* Edit button */}
-
{
- e.stopPropagation();
- handleEditClick(subCourse);
- }}
- >
-
- Edit
-
-
+ {isModOpen ? (
+
+ {loadingSubs ? (
+
+
+ Loading sub-modules…
+
+ ) : !subs || subs.length === 0 ? (
+
No sub-modules.
+ ) : (
+
+ {subs.map((sub) => (
+
+
+
+
+
{sub.title}
+
+ #{sub.id}
+
+ {sub.description ? (
+
+ {sub.description}
+
+ ) : null}
+
+ {!sub.is_active ? (
+
+ Off
+
+ ) : null}
+
openSubModule(sub.id)}
+ >
+ Open
+
+
+
+ ))}
+
+ )}
+
+ ) : null}
+
+ )
+ })
+ )}
+
+ ) : null}
- );
+ )
})}
)}
-
- {/* Delete Modal */}
- {showDeleteModal && subCourseToDelete && (
-
-
-
-
- Delete Course
-
- setShowDeleteModal(false)}
- className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
- >
-
-
-
-
-
-
-
-
-
- Are you sure you want to delete{" "}
-
- {subCourseToDelete.title}
-
- ? This action cannot be undone.
-
-
-
-
- setShowDeleteModal(false)}
- disabled={deleting}
- className="w-full rounded-lg sm:w-auto"
- >
- Cancel
-
-
- {deleting ? "Deleting..." : "Delete"}
-
-
-
-
- )}
-
- {/* Add Sub-module Modal */}
- {showAddModal && (
-
-
-
-
- Add New Course
-
- setShowAddModal(false)}
- className="grid h-10 w-10 min-h-[44px] min-w-[44px] place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 sm:h-8 sm:w-8 sm:min-h-0 sm:min-w-0"
- >
-
-
-
-
-
-
-
-
- Title
-
- setTitle(e.target.value)}
- placeholder="Enter course title"
- className="min-h-[44px]"
- />
-
-
-
- Description
-
-
-
-
- Level
-
- setLevel(e.target.value)}
- className="min-h-[44px] w-full rounded-lg border border-grayScale-200 bg-white px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
- >
- BEGINNER
- INTERMEDIATE
- ADVANCED
- EXPERT
-
-
-
-
- Sub-level
-
- setSubLevel(e.target.value)}
- placeholder='e.g. A1, B2 (CEFR or your scale)'
- className="min-h-[44px]"
- />
-
-
-
- Display order
-
- setDisplayOrder(e.target.value)}
- placeholder="1"
- className="min-h-[44px]"
- />
-
-
-
- Thumbnail
-
-
-
-
-
- Thumbnail URL (optional)
-
- setThumbnailUrl(e.target.value)}
- placeholder="https://..."
- className="min-h-[44px]"
- />
-
-
-
- {saveError && (
-
- )}
-
-
-
-
- setShowAddModal(false)}
- disabled={saving}
- className="w-full rounded-lg sm:w-auto"
- >
- Cancel
-
-
- {saving ? "Saving..." : "Save"}
-
-
-
-
- )}
-
- {/* Prerequisites Modal */}
- {showPrereqModal && prereqSubCourse && (
-
-
-
-
-
- Prerequisites
-
-
- Manage prerequisites for{" "}
-
- {prereqSubCourse.title}
-
-
-
-
setShowPrereqModal(false)}
- className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
- >
-
-
-
-
-
- {/* Add prerequisite */}
- {availablePrerequisites.length > 0 && (
-
-
- Add Prerequisite
-
-
-
- setSelectedPrereqId(Number(e.target.value))
- }
- className="flex-1 rounded-lg border border-grayScale-200 bg-white px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
- >
- Select a course...
- {availablePrerequisites.map((sc) => (
-
- {sc.title} {sc.level ? `(${sc.level})` : ""}
-
- ))}
-
-
- {prereqAdding ? (
-
- ) : (
-
- )}
-
-
-
- )}
-
- {/* Current prerequisites list */}
- {prereqLoading ? (
-
-
-
- ) : prerequisites.length === 0 ? (
-
-
-
- No prerequisites
-
-
- This course is accessible without completing others first
-
-
- ) : (
-
-
- Current Prerequisites ({prerequisites.length})
-
- {prerequisites.map((prereq) => (
-
-
-
- {prereq.prerequisite_title}
-
-
- {prereq.prerequisite_level && (
-
- {prereq.prerequisite_level}
-
- )}
-
- Order: {prereq.prerequisite_display_order}
-
-
-
-
handleRemovePrerequisite(prereq.id)}
- disabled={prereqRemoving === prereq.id}
- className="ml-3 grid h-8 w-8 shrink-0 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-red-50 hover:text-red-500 disabled:opacity-50"
- >
- {prereqRemoving === prereq.id ? (
-
- ) : (
-
- )}
-
-
- ))}
-
- )}
-
-
-
- setShowPrereqModal(false)}
- className="rounded-lg"
- >
- Close
-
-
-
-
- )}
-
- {/* Edit Sub-course Modal */}
- {showEditModal && subCourseToEdit && (
-
-
-
-
- Edit Course
-
- setShowEditModal(false)}
- className="grid h-10 w-10 min-h-[44px] min-w-[44px] place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 sm:h-8 sm:w-8 sm:min-h-0 sm:min-w-0"
- >
-
-
-
-
-
-
-
-
- Title
-
- setTitle(e.target.value)}
- placeholder="Enter course title"
- className="min-h-[44px]"
- />
-
-
-
- Description
-
-
-
-
- Level
-
- setLevel(e.target.value)}
- placeholder="e.g., BEGINNER, Intermediate, Advanced"
- className="min-h-[44px]"
- />
-
- {saveError && (
-
- )}
-
-
-
-
- setShowEditModal(false)}
- disabled={saving}
- className="w-full rounded-lg sm:w-auto"
- >
- Cancel
-
-
- {saving ? "Saving..." : "Save"}
-
-
-
-
- )}
- );
+ )
}
diff --git a/src/pages/issues/IssuesPage.tsx b/src/pages/issues/IssuesPage.tsx
index ebe06c2..136fcfd 100644
--- a/src/pages/issues/IssuesPage.tsx
+++ b/src/pages/issues/IssuesPage.tsx
@@ -95,12 +95,13 @@ function getStatusConfig(status: string): {
}
}
-function getIssueTypeConfig(type: string): {
+function getIssueTypeConfig(type: string | null | undefined): {
label: string;
classes: string;
icon: typeof Bug;
} {
- switch (type) {
+ const t = String(type ?? "").trim();
+ switch (t) {
case "bug":
return {
label: "Bug",
@@ -133,7 +134,7 @@ function getIssueTypeConfig(type: string): {
};
default:
return {
- label: type.charAt(0).toUpperCase() + type.slice(1),
+ label: t ? t.charAt(0).toUpperCase() + t.slice(1) : "Other",
classes: "bg-grayScale-100 text-grayScale-600 border-grayScale-200",
icon: HelpCircle,
};
@@ -173,8 +174,10 @@ function getRelativeTime(dateStr: string): string {
return formatDate(dateStr);
}
-function formatRoleLabel(role: string): string {
- return role
+function formatRoleLabel(role: string | null | undefined): string {
+ const r = String(role ?? "").trim();
+ if (!r) return "—";
+ return r
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(" ");
@@ -221,8 +224,9 @@ export function IssuesPage() {
offset: (page - 1) * pageSize,
};
const res = await getIssues(filters);
- setIssues(res.data.data.issues);
- setTotalCount(res.data.data.total_count);
+ const payload = res.data?.data;
+ setIssues(Array.isArray(payload?.issues) ? payload.issues : []);
+ setTotalCount(typeof payload?.total_count === "number" ? payload.total_count : 0);
} catch (error) {
console.error("Failed to fetch issues:", error);
setIssues([]);
@@ -241,7 +245,7 @@ export function IssuesPage() {
setDetailLoading(true);
try {
const res = await getIssueById(issueId);
- setSelectedIssue(res.data.data);
+ setSelectedIssue(res.data?.data ?? null);
} catch (error) {
console.error("Failed to fetch issue detail:", error);
} finally {
@@ -305,16 +309,15 @@ export function IssuesPage() {
};
// Client-side filtering (status, type, search)
- const filteredIssues = issues.filter((issue) => {
+ const filteredIssues = (Array.isArray(issues) ? issues : []).filter((issue) => {
if (statusFilter && issue.status !== statusFilter) return false;
if (typeFilter && issue.issue_type !== typeFilter) return false;
if (searchQuery) {
const q = searchQuery.toLowerCase();
- return (
- issue.subject.toLowerCase().includes(q) ||
- issue.description.toLowerCase().includes(q) ||
- issue.issue_type.toLowerCase().includes(q)
- );
+ const subject = String(issue.subject ?? "").toLowerCase();
+ const description = String(issue.description ?? "").toLowerCase();
+ const issueType = String(issue.issue_type ?? "").toLowerCase();
+ return subject.includes(q) || description.includes(q) || issueType.includes(q);
}
return true;
});
@@ -537,10 +540,10 @@ export function IssuesPage() {
- {issue.subject}
+ {issue.subject?.trim() ? issue.subject : "—"}
- {issue.description}
+ {issue.description?.trim() ? issue.description : "No description"}
@@ -572,6 +575,9 @@ export function IssuesPage() {
{getStatusConfig(s).label}
))}
+ {!STATUSES.includes(issue.status as (typeof STATUSES)[number]) && issue.status ? (
+
{getStatusConfig(issue.status).label}
+ ) : null}
diff --git a/src/pages/notifications/CreateNotificationPage.tsx b/src/pages/notifications/CreateNotificationPage.tsx
index 1206264..d3ab896 100644
--- a/src/pages/notifications/CreateNotificationPage.tsx
+++ b/src/pages/notifications/CreateNotificationPage.tsx
@@ -1,11 +1,12 @@
import { useEffect, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom"
-import { Bell, Loader2, Mail, MailOpen, Megaphone } from "lucide-react"
+import { Bell, Mail, MailOpen, Megaphone } from "lucide-react"
import { Card, CardContent } from "../../components/ui/card"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea"
import { FileUpload } from "../../components/ui/file-upload"
+import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils"
import { getTeamMembers } from "../../api/team.api"
import type { TeamMember } from "../../types/team.types"
@@ -282,7 +283,7 @@ export function CreateNotificationPage() {
>
{sending ? (
<>
-
+
Sending…
>
) : (
@@ -347,7 +348,7 @@ export function CreateNotificationPage() {
{recipientsLoading && (
-
+
Loading users…
)}
diff --git a/src/pages/role-management/RolesListPage.tsx b/src/pages/role-management/RolesListPage.tsx
index be965c2..78f0072 100644
--- a/src/pages/role-management/RolesListPage.tsx
+++ b/src/pages/role-management/RolesListPage.tsx
@@ -1,8 +1,18 @@
-import { useEffect, useMemo, useState } from "react"
+import { useCallback, useEffect, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom"
import {
- Plus, Search, Shield, ShieldCheck, ChevronLeft, ChevronRight,
- AlertCircle, Eye, X, Pencil, Check,
+ Plus,
+ Search,
+ Shield,
+ ShieldCheck,
+ ChevronLeft,
+ ChevronRight,
+ AlertCircle,
+ Eye,
+ X,
+ Pencil,
+ Check,
+ Trash2,
} from "lucide-react"
import { Button } from "../../components/ui/button"
import { Card, CardContent } from "../../components/ui/card"
@@ -12,7 +22,14 @@ import { Textarea } from "../../components/ui/textarea"
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from "../../components/ui/dialog"
-import { getRoles, getRoleDetail, getAllPermissions, setRolePermissions, updateRole } from "../../api/rbac.api"
+import {
+ getRoles,
+ getRoleDetail,
+ getAllPermissions,
+ setRolePermissions,
+ updateRole,
+ deleteRole,
+} from "../../api/rbac.api"
import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
import { cn } from "../../lib/utils"
import { toast } from "sonner"
@@ -36,6 +53,11 @@ export function RolesListPage() {
const [detailOpen, setDetailOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
+ // Delete modal state
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
+ const [roleToDelete, setRoleToDelete] = useState
(null)
+ const [deleteLoading, setDeleteLoading] = useState(false)
+
// Role info editing state
const [editingRole, setEditingRole] = useState(false)
const [editName, setEditName] = useState("")
@@ -59,27 +81,28 @@ export function RolesListPage() {
return () => clearTimeout(timer)
}, [query])
+ const fetchRoles = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const res = await getRoles({
+ query: debouncedQuery || undefined,
+ page,
+ page_size: pageSize,
+ })
+ setRoles(res.data.data.roles ?? [])
+ setTotal(res.data.data.total ?? 0)
+ } catch {
+ setError("Failed to load roles.")
+ } finally {
+ setLoading(false)
+ }
+ }, [debouncedQuery, page, pageSize])
+
// Fetch roles
useEffect(() => {
- const fetchRoles = async () => {
- setLoading(true)
- setError(null)
- try {
- const res = await getRoles({
- query: debouncedQuery || undefined,
- page,
- page_size: pageSize,
- })
- setRoles(res.data.data.roles ?? [])
- setTotal(res.data.data.total ?? 0)
- } catch {
- setError("Failed to load roles.")
- } finally {
- setLoading(false)
- }
- }
fetchRoles()
- }, [debouncedQuery, page, pageSize])
+ }, [fetchRoles])
// Open role detail
const handleViewRole = async (roleId: number) => {
@@ -97,6 +120,45 @@ export function RolesListPage() {
}
}
+ const handleDeleteRoleClick = (role: Role) => {
+ setRoleToDelete(role)
+ setDeleteDialogOpen(true)
+ }
+
+ const handleCancelDeleteRole = () => {
+ setDeleteDialogOpen(false)
+ setRoleToDelete(null)
+ }
+
+ const handleConfirmDeleteRole = async () => {
+ if (!roleToDelete) return
+ setDeleteLoading(true)
+ try {
+ const res = await deleteRole(roleToDelete.id)
+ toast.success(res.data.message ?? "Role deleted successfully")
+
+ // Close dialogs if the deleted role is currently opened.
+ if (selectedRole?.id === roleToDelete.id) {
+ setDetailOpen(false)
+ setSelectedRole(null)
+ setEditingPermissions(false)
+ setEditingRole(false)
+ setPermSearch("")
+ }
+
+ setRoleToDelete(null)
+ setDeleteDialogOpen(false)
+ await fetchRoles()
+ } catch (err: unknown) {
+ const message =
+ (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
+ "Failed to delete role."
+ toast.error(message)
+ } finally {
+ setDeleteLoading(false)
+ }
+ }
+
// Enter role info edit mode
const handleEditRole = () => {
if (!selectedRole) return
@@ -302,7 +364,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()}
+
+
+
+
+
- Created {new Date(role.created_at).toLocaleDateString()}
+ Open details to view permissions
-
handleViewRole(role.id)}
- >
-
- View
-
+
+ {!role.is_system && (
+ handleDeleteRoleClick(role)}
+ disabled={deleteLoading}
+ aria-label={`Delete role ${role.name}`}
+ >
+
+
+ )}
+ handleViewRole(role.id)}
+ >
+
+ View
+
+
@@ -689,6 +782,55 @@ export function RolesListPage() {
)}
+
+ {/* Delete role dialog */}
+
{
+ setDeleteDialogOpen(open)
+ if (!open) handleCancelDeleteRole()
+ }}
+ >
+
+
+
+
+ Delete Role
+
+
+ Are you sure you want to delete this role? This action cannot be undone.
+
+
+
+ {roleToDelete && (
+
+
{roleToDelete.name}
+
Role #{roleToDelete.id}
+
+ )}
+
+
+
+ Cancel
+
+
+ {deleteLoading ? : }
+ {deleteLoading ? "Deleting..." : "Delete"}
+
+
+
+
)
}
diff --git a/src/types/course.types.ts b/src/types/course.types.ts
index 4e08ba1..2ec0d47 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
}
@@ -172,7 +173,13 @@ export interface GetModulesResponse {
export interface CreateModuleRequest {
level_id: number
title: string
- content: string
+ /** Legacy field kept for backward compatibility. */
+ content?: string
+ /** Preferred field for module detail text. */
+ description?: string
+ icon_url?: string
+ display_order?: number
+ is_active?: boolean
}
/** @deprecated Use UpdateSubCourseRequest instead */
@@ -192,6 +199,8 @@ export interface UpdateModuleStatusRequest {
export interface SubCourse {
id: number
course_id: number
+ /** Present when derived from course hierarchy rows (levels → modules → sub-modules). */
+ level_id?: number
module_id?: number
title: string
description: string
@@ -701,6 +710,72 @@ export interface HumanLanguageLesson {
practices: LearningPathPractice[]
}
+export interface SubModuleLessonDetail {
+ id: number
+ sub_module_id: number
+ display_order: number
+ is_active: boolean
+ created_at: string
+ title: string
+ description?: string | null
+ thumbnail?: string | null
+ teaching_text?: string | null
+ teaching_image_url?: string | null
+ teaching_audio_url?: string | null
+ teaching_video_url?: string | null
+}
+
+export interface SubModuleLesson {
+ id: number
+ sub_module_id: number
+ display_order: number
+ is_active: boolean
+ created_at: string
+ title: string
+ description?: string | null
+ thumbnail?: string | null
+ teaching_text?: string | null
+ teaching_image_url?: string | null
+ teaching_audio_url?: string | null
+ teaching_video_url?: string | null
+}
+
+export interface GetSubModuleLessonDetailResponse {
+ message: string
+ data: SubModuleLessonDetail
+ success: boolean
+ status_code: number
+ metadata: unknown
+}
+
+export interface UpdateSubModuleLessonRequest {
+ title: string
+ description?: string | null
+ thumbnail?: string | null
+ teaching_text?: string | null
+ teaching_image_url?: string | null
+ teaching_audio_url?: string | null
+ teaching_video_url?: string | null
+ display_order: number
+ is_active: boolean
+}
+
+export interface UpdateSubModuleLessonResponse {
+ message: string
+ data: SubModuleLessonDetail
+ success: boolean
+ status_code: number
+ metadata: unknown
+}
+
+export interface GetSubModuleLessonsResponse {
+ message: string
+ data: SubModuleLesson[]
+ success: boolean
+ status_code: number
+ metadata: unknown
+}
+
export interface GetHumanLanguageLessonsResponse {
message: string
data: {
@@ -714,10 +789,209 @@ export interface GetHumanLanguageLessonsResponse {
metadata: unknown
}
+/** Row from GET /course-management/human-language/sub-categories */
+export interface HumanLanguageSubCategoryListItem {
+ id: number
+ category_id: number
+ category_name: string
+ name: string
+ description?: string | null
+ display_order: number
+ is_active: boolean
+ created_at: string
+ /** Present on some payloads; ignore if unused. */
+ total_count?: number
+}
+
+export interface GetHumanLanguageSubCategoriesResponse {
+ message: string
+ data: {
+ sub_categories: HumanLanguageSubCategoryListItem[]
+ total_count: number
+ }
+ success: boolean
+ status_code: number
+ metadata: unknown
+}
+
+/** Row from GET /course-management/categories/:categoryId/sub-categories */
+export interface CategorySubCategoryListItem {
+ id: number
+ category_id: number
+ category_name: string
+ name: string
+ description?: string | null
+ display_order: number
+ is_active: boolean
+ created_at: string
+ /** Sometimes echoed per row by the API; safe to ignore. */
+ total_count?: number
+}
+
+export interface GetCategorySubCategoriesResponse {
+ message: string
+ data: {
+ sub_categories: CategorySubCategoryListItem[]
+ total_count: number
+ }
+ success: boolean
+ status_code: number
+ metadata: unknown
+}
+
+/** Row from GET /course-management/sub-categories/:subCategoryId/courses */
+export interface SubCategoryCourseListItem {
+ id: number
+ category_id: number
+ sub_category_id: number
+ title: string
+ description?: string | null
+ thumbnail?: string | null
+ intro_video_url?: string | null
+ is_active: boolean
+ total_count?: number
+}
+
+export interface GetSubCategoryCoursesResponse {
+ message: string
+ data: {
+ courses: SubCategoryCourseListItem[]
+ total_count: number
+ }
+ success: boolean
+ status_code: number
+ metadata: unknown
+}
+
+/** Row from GET /course-management/courses/:courseId/levels or GET /course-management/levels */
+export interface CourseLevelRow {
+ id: number
+ course_id: number
+ cefr_level: string
+ display_order: number
+ is_active: boolean
+ created_at: string
+ title: string
+ description?: string | null
+ thumbnail?: string | null
+ total_count?: number
+}
+
+export interface GetCourseLevelsForCourseResponse {
+ message: string
+ data: {
+ levels: CourseLevelRow[]
+ total_count: number
+ }
+ success: boolean
+ status_code: number
+ metadata: unknown
+}
+
+export interface GetCourseLevelsAllResponse {
+ message: string
+ data: {
+ levels: CourseLevelRow[]
+ total_count: number
+ }
+ success: boolean
+ status_code: number
+ metadata: unknown
+}
+
+export interface GetCourseLevelByIdResponse {
+ message: string
+ data: CourseLevelRow
+ success: boolean
+ status_code: number
+ metadata: unknown
+}
+
+/** Row from GET /course-management/modules/:moduleId/sub-modules */
+export interface CourseSubModuleListItem {
+ id: number
+ module_id: number
+ title: string
+ description?: string | null
+ display_order: number
+ is_active: boolean
+ created_at: string
+ legacy_sub_course_id?: number | null
+ thumbnail?: string | null
+ tips?: string | null
+ total_count?: number
+}
+
+export interface GetSubModulesByModuleResponse {
+ message: string
+ data: {
+ sub_modules: CourseSubModuleListItem[]
+ total_count: number
+ }
+ success: boolean
+ status_code: number
+ metadata: unknown
+}
+
+/** Row from GET /course-management/human-language/hierarchy */
+export interface HumanLanguageHierarchyFlatRow {
+ category_id: number
+ category_name: string
+ sub_category_id?: number | null
+ sub_category_name?: string | null
+ course_id?: number | null
+ course_title?: string | null
+}
+
+export interface GetHumanLanguageHierarchyFlatResponse {
+ message: string
+ data: HumanLanguageHierarchyFlatRow[]
+ success: boolean
+ status_code: number
+ metadata: unknown
+}
+
+/** Row from GET /course-management/courses/:courseId/hierarchy */
+export interface CourseHierarchyRow {
+ course_id: number
+ course_title: string
+ level_id?: number | null
+ cefr_level?: string | null
+ level_title?: string | null
+ level_description?: string | null
+ level_thumbnail?: string | null
+ module_id?: number | null
+ module_title?: string | null
+ module_icon_url?: string | null
+ sub_module_id?: number | null
+ sub_module_title?: string | null
+ sub_module_description?: string | null
+ sub_module_thumbnail?: string | null
+ sub_module_tips?: string | null
+ sub_module_display_order?: number | null
+}
+
+export interface GetCourseHierarchyResponse {
+ message: string
+ data: CourseHierarchyRow[]
+ success: boolean
+ status_code: number
+ metadata: unknown
+}
+
export interface HumanLanguageSubModule {
id: number
title: string
videos: LearningPathVideo[]
+ lessons?: {
+ id: number
+ question_set_id: number
+ title: string
+ status: string
+ question_count: number
+ display_order: number
+ intro_video_url?: string | null
+ }[]
practices: LearningPathPractice[]
}
@@ -728,6 +1002,7 @@ export interface HumanLanguageModule {
}
export interface HumanLanguageLevelTree {
+ level_id?: number
level: string
modules: HumanLanguageModule[]
}
diff --git a/src/types/rbac.types.ts b/src/types/rbac.types.ts
index 28721f1..b814aa0 100644
--- a/src/types/rbac.types.ts
+++ b/src/types/rbac.types.ts
@@ -60,6 +60,14 @@ export interface CreateRoleResponse {
metadata: unknown
}
+export interface DeleteRoleResponse {
+ message: string
+ success: boolean
+ status_code: number
+ // Some backends may include extra fields; keep it optional for compatibility.
+ metadata?: unknown
+}
+
export interface SetRolePermissionsRequest {
permission_ids: number[]
}