Merge branch 'el-ui' into main (prefer el-ui on conflicts)

This commit is contained in:
Yared Yemane 2026-04-24 05:51:37 -07:00
commit dc07ab72d2
25 changed files with 5606 additions and 5097 deletions

View File

@ -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 <access_token>`.
- 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=<object_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.

View File

@ -47,8 +47,20 @@ import type {
GetSubCoursePrerequisitesResponse, GetSubCoursePrerequisitesResponse,
AddSubCoursePrerequisiteRequest, AddSubCoursePrerequisiteRequest,
GetLearningPathResponse, GetLearningPathResponse,
GetSubModuleLessonDetailResponse,
GetHumanLanguageLessonsResponse, GetHumanLanguageLessonsResponse,
GetHumanLanguageHierarchyResponse, GetSubModuleLessonsResponse,
GetHumanLanguageSubCategoriesResponse,
GetCategorySubCategoriesResponse,
GetSubCategoryCoursesResponse,
GetCourseLevelsForCourseResponse,
GetCourseLevelsAllResponse,
GetCourseLevelByIdResponse,
GetHumanLanguageHierarchyFlatResponse,
GetCourseHierarchyResponse,
GetSubModulesByModuleResponse,
CourseHierarchyRow,
SubCourse,
CreateHumanLanguageLessonRequest, CreateHumanLanguageLessonRequest,
GetSubCourseEntryAssessmentResponse, GetSubCourseEntryAssessmentResponse,
ReorderItem, ReorderItem,
@ -56,6 +68,8 @@ import type {
GetRatingsParams, GetRatingsParams,
GetVimeoSampleResponse, GetVimeoSampleResponse,
CreateCourseVideoRequest, CreateCourseVideoRequest,
UpdateSubModuleLessonRequest,
UpdateSubModuleLessonResponse,
} from "../types/course.types" } from "../types/course.types"
type UnifiedHierarchyRow = { type UnifiedHierarchyRow = {
@ -67,21 +81,22 @@ type UnifiedHierarchyRow = {
course_title?: string | null course_title?: string | null
} }
type CourseHierarchyRow = { async function withSingleRetry<T>(request: () => Promise<T>, retryDelayMs = 400): Promise<T> {
course_id: number try {
course_title: string return await request()
level_id?: number | null } catch {
cefr_level?: string | null await new Promise((resolve) => setTimeout(resolve, retryDelayMs))
module_id?: number | null return request()
module_title?: string | null }
sub_module_id?: number | null
sub_module_title?: string | null
} }
export const getCourseCategories = () => 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 rows: UnifiedHierarchyRow[] = res.data?.data ?? []
const categoriesMap = new Map<number, { id: number; name: string; is_active: boolean; created_at: string }>() const categoriesMap = new Map<
number,
{ id: number; name: string; is_active: boolean; created_at: string; subCategoryCount: number; courseCount: number }
>()
rows.forEach((r) => { rows.forEach((r) => {
if (!categoriesMap.has(r.category_id)) { if (!categoriesMap.has(r.category_id)) {
categoriesMap.set(r.category_id, { categoriesMap.set(r.category_id, {
@ -89,10 +104,50 @@ export const getCourseCategories = () =>
name: r.category_name, name: r.category_name,
is_active: true, is_active: true,
created_at: new Date().toISOString(), 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<string, CategoryAggregate>()
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 { return {
...res, ...res,
data: { 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/sub-categories", { category_id: data.parent_id, name: data.name })
: http.post("/course-management/categories", { 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<GetCategorySubCategoriesResponse>(`/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) => 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 rows: UnifiedHierarchyRow[] = res.data?.data ?? []
const courses = rows
.filter((r) => r.category_id === categoryId && r.course_id) const requestedCategoryRows = rows.filter((r) => r.category_id === categoryId)
.map((r) => ({ const requestedCategoryName = requestedCategoryRows.find((r) => !!r.category_name)?.category_name?.trim().toLowerCase()
id: Number(r.course_id), const relevantRows = requestedCategoryName
? rows.filter((r) => r.category_name?.trim().toLowerCase() === requestedCategoryName)
: requestedCategoryRows
const courseMap = new Map<number, { id: number; category_id: number; sub_category_id: number | null; title: string; description: string; thumbnail: string; is_active: boolean }>()
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, category_id: r.category_id,
sub_category_id: r.sub_category_id ?? null, sub_category_id: r.sub_category_id ?? null,
title: r.course_title ?? "", title: r.course_title ?? "",
description: "", description: "",
thumbnail: "", thumbnail: "",
is_active: true, is_active: true,
})) })
})
const courses = Array.from(courseMap.values())
return { return {
...res, ...res,
data: { ...res.data, data: { courses, total_count: courses.length } }, 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) => export const updateCourse = (courseId: number, data: UpdateCourseRequest) =>
http.put(`/course-management/courses/${courseId}`, data) http.put(`/course-management/courses/${courseId}`, data)
export const getCourseHierarchyByCourseId = (courseId: number) =>
http.get<GetCourseHierarchyResponse>(`/course-management/courses/${courseId}/hierarchy`)
// Sub-Module APIs (Unified Hierarchy) // Sub-Module APIs (Unified Hierarchy)
export const getSubModulesByCourse = (courseId: number) => export const getSubModulesByCourse = (courseId: number) =>
http.get(`/course-management/courses/${courseId}/hierarchy`).then((res) => { getCourseHierarchyByCourseId(courseId).then((res) => {
const rows: CourseHierarchyRow[] = res.data?.data ?? [] const raw = res.data?.data
const subModuleMap = new Map<number, { id: number; course_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 }>() 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) => { rows.forEach((r, idx) => {
if (!r.sub_module_id) return 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, { subModuleMap.set(r.sub_module_id, {
id: r.sub_module_id, id: r.sub_module_id,
course_id: courseId, course_id: courseId,
level_id: r.level_id ?? undefined,
module_id: r.module_id ?? undefined, module_id: r.module_id ?? undefined,
title: r.sub_module_title ?? "", title: r.sub_module_title ?? "",
description: "", description: "",
@ -168,7 +286,17 @@ export const getSubModulesByCourse = (courseId: number) =>
sub_level: r.cefr_level ?? undefined, sub_level: r.cefr_level ?? undefined,
is_active: true, 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()) const sub_courses = Array.from(subModuleMap.values())
return { return {
@ -225,6 +353,33 @@ export const deleteSubModule = (subModuleId: number) =>
export const getVideosBySubModule = (subModuleId: number) => export const getVideosBySubModule = (subModuleId: number) =>
http.get<GetSubCourseVideosResponse>(`/course-management/sub-modules/${subModuleId}/videos`) http.get<GetSubCourseVideosResponse>(`/course-management/sub-modules/${subModuleId}/videos`)
export const getLessonsBySubModule = (subModuleId: number, options?: { includeInactive?: boolean }) =>
http.get<GetSubModuleLessonsResponse>(`/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<GetSubModuleLessonDetailResponse>(`/course-management/sub-module-lessons/${lessonId}`, {
params: options?.cacheBust ? { _t: Date.now() } : undefined,
})
export const updateSubModuleLesson = (lessonId: number, data: UpdateSubModuleLessonRequest) =>
http.put<UpdateSubModuleLessonResponse>(`/course-management/sub-module-lessons/${lessonId}`, data)
export const softDeleteSubModuleLesson = (lessonId: number) =>
http.put<UpdateSubModuleLessonResponse>(`/course-management/sub-module-lessons/${lessonId}`, {
is_active: false,
})
export const createSubCourseVideo = (data: CreateSubCourseVideoRequest) => export const createSubCourseVideo = (data: CreateSubCourseVideoRequest) =>
http.post("/course-management/sub-module-videos", { http.post("/course-management/sub-module-videos", {
sub_module_id: data.sub_module_id ?? data.sub_course_id, sub_module_id: data.sub_module_id ?? data.sub_course_id,
@ -285,6 +440,43 @@ export const createPractice = (data: CreatePracticeRequest) =>
.then(() => res) .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<CreateQuestionSetResponse>("/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) => export const updatePractice = (practiceId: number, data: UpdatePracticeRequest) =>
http.put(`/course-management/practices/${practiceId}`, data) http.put(`/course-management/practices/${practiceId}`, data)
@ -506,186 +698,92 @@ export const getHumanLanguageLessonsByCourse = (courseId: number, cefr_level: st
params: { cefr_level }, params: { cefr_level },
}) })
export const getHumanLanguageHierarchy = () => export const getHumanLanguageSubCategories = () =>
http.get<GetHumanLanguageHierarchyResponse>("/course-management/hierarchy").then(async (res) => { http.get<GetHumanLanguageSubCategoriesResponse>("/course-management/human-language/sub-categories")
const payload = res.data?.data as unknown
if (payload && typeof payload === "object" && !Array.isArray(payload) && "sub_categories" in payload) {
return res
}
const rows: UnifiedHierarchyRow[] = Array.isArray(payload) ? payload : [] export const getCoursesBySubCategoryId = (subCategoryId: number) =>
const categoryMap = new Map< http.get<GetSubCategoryCoursesResponse>(`/course-management/sub-categories/${subCategoryId}/courses`)
number,
{
category_id: number
category_name: string
sub_categories: Map<
number,
{
sub_category_id: number
sub_category_name: string
courses: Map<
number,
{
course_id: number
course_name: string
}
>
}
>
}
>()
rows.forEach((row) => { export const getSubModulesByModuleId = (moduleId: number) =>
const categoryId = Number(row.category_id) http.get<GetSubModulesByModuleResponse>(`/course-management/modules/${moduleId}/sub-modules`)
if (!Number.isFinite(categoryId)) return
if (!categoryMap.has(categoryId)) { /**
categoryMap.set(categoryId, { * Finds a sub-module under a course by walking levels modules sub-modules APIs.
category_id: categoryId, * Use when the legacy hierarchy flatten (`getSubModulesByCourse`) does not include the row.
category_name: row.category_name ?? "", */
sub_categories: new Map(), export async function resolveSubModuleForCourse(
}) courseId: number,
} subModuleId: number,
): Promise<SubCourse | null> {
if (!row.sub_category_id) return try {
const subCategoryId = Number(row.sub_category_id) const levelsRes = await getCourseLevelsForCourse(courseId)
if (!Number.isFinite(subCategoryId)) return const levels = Array.isArray(levelsRes.data?.data?.levels) ? levelsRes.data.data.levels : []
const sortedLevels = [...levels].sort((a, b) => {
const categoryNode = categoryMap.get(categoryId)! const o = (a.display_order ?? 0) - (b.display_order ?? 0)
if (!categoryNode.sub_categories.has(subCategoryId)) { if (o !== 0) return o
categoryNode.sub_categories.set(subCategoryId, { return String(a.cefr_level ?? "").localeCompare(String(b.cefr_level ?? ""))
sub_category_id: subCategoryId,
sub_category_name: row.sub_category_name ?? "",
courses: new Map(),
})
}
if (!row.course_id) return
const courseId = Number(row.course_id)
if (!Number.isFinite(courseId)) return
const subCategoryNode = categoryNode.sub_categories.get(subCategoryId)!
if (!subCategoryNode.courses.has(courseId)) {
subCategoryNode.courses.set(courseId, {
course_id: courseId,
course_name: row.course_title ?? "",
})
}
}) })
const selectedCategory = const modulesNested = await Promise.all(
Array.from(categoryMap.values()).find((c) => c.category_name.toLowerCase().includes("human")) ?? sortedLevels.map(async (level) => {
Array.from(categoryMap.values())[0] const modsRes = await getModulesByLevel(level.id)
const rawMods = modsRes.data?.data?.modules
if (!selectedCategory) { const modules = Array.isArray(rawMods) ? rawMods : []
return { const sortedMods = [...modules].sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
...res, return sortedMods.map((module) => ({ level, module }))
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<number, CourseHierarchyRow[]>(
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<number, { id: number; title: string; videos: []; practices: [] }>
}
>
}
>()
;(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 modulePairs = modulesNested.flat()
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 { return {
...res, id: found.id,
data: { course_id: courseId,
...res.data, level_id: level.id,
data: { module_id: module.id,
category_id: selectedCategory.category_id, title: found.title,
category_name: selectedCategory.category_name, description: found.description ?? "",
sub_categories: subCategories, level: level.cefr_level,
}, cefr_level: level.cefr_level,
}, thumbnail: found.thumbnail ?? "",
} as unknown as { data: GetHumanLanguageHierarchyResponse } 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<GetCourseLevelsForCourseResponse>(`/course-management/courses/${courseId}/levels`)
export const getAllCourseLevels = () => http.get<GetCourseLevelsAllResponse>("/course-management/levels")
export const getCourseLevelById = (levelId: number) =>
http.get<GetCourseLevelByIdResponse>(`/course-management/levels/${levelId}`)
export const getHumanLanguageHierarchy = (options?: { cacheBust?: boolean }) =>
withSingleRetry(() =>
http.get<GetHumanLanguageHierarchyFlatResponse>("/course-management/human-language/hierarchy", {
params: options?.cacheBust ? { _t: Date.now() } : undefined,
}),
)
export const createHumanLanguageLesson = (data: CreateHumanLanguageLessonRequest) => export const createHumanLanguageLesson = (data: CreateHumanLanguageLessonRequest) =>
http 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) => export const getSubModuleEntryAssessment = (subModuleId: number) =>
http.get<GetSubCourseEntryAssessmentResponse>( http.get<GetSubCourseEntryAssessmentResponse>(
`/question-sets/sub-courses/${subModuleId}/entry-assessment`, `/question-sets/sub-courses/${subModuleId}/entry-assessment`,

View File

@ -12,6 +12,7 @@ let failedQueue: Array<{
resolve: (token: string) => void; resolve: (token: string) => void;
reject: (error: Error) => void; reject: (error: Error) => void;
}> = []; }> = [];
const TOKEN_REFRESH_BUFFER_SECONDS = 120;
const processQueue = (error: Error | null, token: string | null = null) => { const processQueue = (error: Error | null, token: string | null = null) => {
failedQueue.forEach((prom) => { failedQueue.forEach((prom) => {
@ -32,23 +33,47 @@ const clearAuthAndRedirect = () => {
window.location.href = "/login"; window.location.href = "/login";
}; };
const refreshAccessToken = async (): Promise<string> => { const decodeJwtPayload = (token: string): Record<string, unknown> | null => {
const accessToken = localStorage.getItem("access_token"); try {
const refreshToken = localStorage.getItem("refresh_token"); const payloadPart = token.split(".")[1];
const role = localStorage.getItem("role"); if (!payloadPart) return null;
const memberId = localStorage.getItem("member_id"); 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<string, unknown>;
} 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<string> => {
const refreshToken = localStorage.getItem("refresh_token");
if (!refreshToken) {
throw new Error("No refresh token available"); throw new Error("No refresh token available");
} }
const response = await axios.post( 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, refresh_token: refreshToken,
role: role || "admin",
member_id: Number(memberId),
} }
); );
@ -65,9 +90,43 @@ const refreshAccessToken = async (): Promise<string> => {
return newAccessToken; return newAccessToken;
}; };
const getValidAccessToken = async (forceRefresh = false): Promise<string> => {
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 // Attach access token to every request
http.interceptors.request.use((config) => { http.interceptors.request.use(async (config) => {
const token = localStorage.getItem("access_token"); if (isAuthEndpointRequest(config.url)) {
return config;
}
let token = localStorage.getItem("access_token");
if (token && isAccessTokenExpiringSoon(token)) {
token = await getValidAccessToken();
}
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }
@ -80,32 +139,19 @@ http.interceptors.response.use(
async (error: AxiosError) => { async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
if (error.response?.status === 401 && !originalRequest._retry) { if (
if (isRefreshing) { error.response?.status === 401 &&
return new Promise((resolve, reject) => { !originalRequest._retry &&
failedQueue.push({ resolve, reject }); !isAuthEndpointRequest(originalRequest.url)
}) ) {
.then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return http(originalRequest);
})
.catch((err) => Promise.reject(err));
}
originalRequest._retry = true; originalRequest._retry = true;
isRefreshing = true;
try { try {
const newToken = await refreshAccessToken(); const newToken = await getValidAccessToken(true);
processQueue(null, newToken);
originalRequest.headers.Authorization = `Bearer ${newToken}`; originalRequest.headers.Authorization = `Bearer ${newToken}`;
return http(originalRequest); return http(originalRequest);
} catch (refreshError) { } catch (refreshError) {
processQueue(refreshError as Error, null);
clearAuthAndRedirect();
return Promise.reject(refreshError); return Promise.reject(refreshError);
} finally {
isRefreshing = false;
} }
} }

View File

@ -5,6 +5,7 @@ import type {
GetRolesParams, GetRolesParams,
CreateRoleRequest, CreateRoleRequest,
CreateRoleResponse, CreateRoleResponse,
DeleteRoleResponse,
SetRolePermissionsRequest, SetRolePermissionsRequest,
GetPermissionsResponse, GetPermissionsResponse,
} from "../types/rbac.types" } from "../types/rbac.types"
@ -26,3 +27,6 @@ export const setRolePermissions = (roleId: number, data: SetRolePermissionsReque
export const getAllPermissions = () => export const getAllPermissions = () =>
http.get<GetPermissionsResponse>("/rbac/permissions") http.get<GetPermissionsResponse>("/rbac/permissions")
export const deleteRole = (roleId: number) =>
http.delete<DeleteRoleResponse>(`/rbac/roles/${roleId}`)

View File

@ -10,8 +10,8 @@ import { ContentOverviewPage } from "../pages/content-management/ContentOverview
import { CoursesPage } from "../pages/content-management/CoursesPage" import { CoursesPage } from "../pages/content-management/CoursesPage"
import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuestionsPage" import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuestionsPage"
import { AddNewPracticePage } from "../pages/content-management/AddNewPracticePage" import { AddNewPracticePage } from "../pages/content-management/AddNewPracticePage"
import { AddNewLessonPage } from "../pages/content-management/AddNewLessonPage"
import { SubModulesPage } from "../pages/content-management/SubCoursesPage" import { SubModulesPage } from "../pages/content-management/SubCoursesPage"
import { SubModuleContentPage } from "../pages/content-management/SubCourseContentPage"
import { SpeakingPage } from "../pages/content-management/SpeakingPage" import { SpeakingPage } from "../pages/content-management/SpeakingPage"
import { AddVideoPage } from "../pages/content-management/AddVideoPage" import { AddVideoPage } from "../pages/content-management/AddVideoPage"
import { AddPracticePage } from "../pages/content-management/AddPracticePage" 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 { PracticeMembersPage } from "../pages/content-management/PracticeMembersPage"
import { QuestionsPage } from "../pages/content-management/QuestionsPage" import { QuestionsPage } from "../pages/content-management/QuestionsPage"
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage" 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 { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage"
import { SubCategoryCoursesPage } from "../pages/content-management/SubCategoryCoursesPage"
import { UserLogPage } from "../pages/user-log/UserLogPage" import { UserLogPage } from "../pages/user-log/UserLogPage"
import { IssuesPage } from "../pages/issues/IssuesPage" import { IssuesPage } from "../pages/issues/IssuesPage"
import { ProfilePage } from "../pages/ProfilePage" import { ProfilePage } from "../pages/ProfilePage"
@ -78,30 +79,44 @@ export function AppRoutes() {
<Route index element={<CourseCategoryPage />} /> <Route index element={<CourseCategoryPage />} />
<Route path="courses" element={<AllCoursesPage />} /> <Route path="courses" element={<AllCoursesPage />} />
<Route path="flows" element={<CourseFlowBuilderPage />} /> <Route path="flows" element={<CourseFlowBuilderPage />} />
<Route path="human-language" element={<HumanLanguagePage />} /> <Route path="human-language" element={<HumanLanguageHierarchyPage />} />
<Route <Route
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/add-practice" path="human-language/:categoryId/:courseId/sub-module/:subModuleId/add-practice"
element={<AddNewPracticePage />} element={<AddNewPracticePage />}
/> />
<Route
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/add-lesson"
element={<AddNewLessonPage />}
/>
<Route <Route
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/practices/:practiceId/questions" path="human-language/:categoryId/:courseId/sub-module/:subModuleId/practices/:practiceId/questions"
element={<PracticeQuestionsPage />} element={<PracticeQuestionsPage />}
/> />
<Route
path="human-language/:categoryId/:courseId/level/:levelId/practices/:practiceId/questions"
element={<PracticeQuestionsPage />}
/>
<Route <Route
path="human-language/:categoryId/:courseId/sub-module/:subModuleId" path="human-language/:categoryId/:courseId/sub-module/:subModuleId"
element={<HumanLanguageSubModulePage />} element={<HumanLanguageSubModulePage />}
/> />
<Route path="category/:categoryId" element={<ContentOverviewPage />} /> <Route path="category/:categoryId" element={<ContentOverviewPage />} />
<Route
path="category/:categoryId/sub-categories/:subCategoryId/courses"
element={<SubCategoryCoursesPage />}
/>
<Route path="category/:categoryId/courses" element={<CoursesPage />} /> <Route path="category/:categoryId/courses" element={<CoursesPage />} />
{/* Course → Sub-module → Lesson/Practice */} {/* Course → Sub-module → Lesson/Practice */}
<Route path="category/:categoryId/courses/:courseId/sub-modules" element={<SubModulesPage />} /> <Route path="category/:categoryId/courses/:courseId/sub-modules" element={<SubModulesPage />} />
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId" element={<SubModuleContentPage />} /> <Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId" element={<HumanLanguageSubModulePage />} />
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/add-practice" element={<AddNewPracticePage />} /> <Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/add-practice" element={<AddNewPracticePage />} />
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/add-lesson" element={<AddNewLessonPage />} />
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} /> <Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} />
{/* Legacy aliases */} {/* Legacy aliases */}
<Route path="category/:categoryId/courses/:courseId/sub-courses" element={<SubModulesPage />} /> <Route path="category/:categoryId/courses/:courseId/sub-courses" element={<SubModulesPage />} />
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId" element={<SubModuleContentPage />} /> <Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId" element={<HumanLanguageSubModulePage />} />
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/add-practice" element={<AddNewPracticePage />} /> <Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/add-practice" element={<AddNewPracticePage />} />
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/add-lesson" element={<AddNewLessonPage />} />
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} /> <Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} />
<Route path="category/:categoryId/courses/add-video" element={<AddVideoPage />} /> <Route path="category/:categoryId/courses/add-video" element={<AddVideoPage />} />
<Route path="speaking" element={<SpeakingPage />} /> <Route path="speaking" element={<SpeakingPage />} />

View File

@ -45,7 +45,7 @@ export function Topbar({ onSidebarToggle }: TopbarProps) {
} }
return ( return (
<header className="sticky top-0 z-10 flex h-16 items-center justify-between gap-3 border-b bg-grayScale-50/85 px-4 backdrop-blur lg:justify-end lg:px-6"> <header className="sticky top-0 z-40 flex h-16 items-center justify-between gap-3 border-b bg-grayScale-50/85 px-4 backdrop-blur lg:justify-end lg:px-6">
{/* Sidebar toggle */} {/* Sidebar toggle */}
<button <button
type="button" type="button"

View File

@ -1,16 +1,18 @@
import { useState, useCallback } from "react" import { useState, useCallback, useEffect, useMemo, useRef } from "react"
import { Navigate, Outlet } from "react-router-dom" import { Navigate, Outlet, useLocation } from "react-router-dom"
import { Sidebar } from "../components/sidebar/Sidebar" import { Sidebar } from "../components/sidebar/Sidebar"
import { Topbar } from "../components/topbar/Topbar" import { Topbar } from "../components/topbar/Topbar"
export function AppLayout() { export function AppLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false) const [sidebarOpen, setSidebarOpen] = useState(false)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const mainRef = useRef<HTMLElement | null>(null)
const previousRouteKeyRef = useRef<string>("")
const location = useLocation()
const scrollStoragePrefix = "app:scroll:"
const routeKey = useMemo(() => `${location.pathname}${location.search}`, [location.pathname, location.search])
const token = localStorage.getItem("access_token") const token = localStorage.getItem("access_token")
if (!token) {
return <Navigate to="/login" replace />
}
const handleSidebarToggle = useCallback(() => { const handleSidebarToggle = useCallback(() => {
setSidebarOpen((prev) => !prev) setSidebarOpen((prev) => !prev)
@ -20,6 +22,43 @@ export function AppLayout() {
setSidebarOpen(false) 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 <Navigate to="/login" replace />
}
return ( return (
<div className="flex min-h-screen bg-grayScale-100"> <div className="flex min-h-screen bg-grayScale-100">
<Sidebar <Sidebar
@ -34,7 +73,7 @@ export function AppLayout() {
}`} }`}
> >
<Topbar onSidebarToggle={handleSidebarToggle} /> <Topbar onSidebarToggle={handleSidebarToggle} />
<main className="min-w-0 flex-1 overflow-x-hidden overflow-y-auto px-3 pb-8 pt-4 sm:px-4 lg:px-6"> <main ref={mainRef} className="min-w-0 flex-1 overflow-x-hidden overflow-y-auto px-3 pb-8 pt-4 sm:px-4 lg:px-6">
<Outlet /> <Outlet />
</main> </main>
<footer className="border-t bg-grayScale-50 px-4 py-3 lg:px-6"> <footer className="border-t bg-grayScale-50 px-4 py-3 lg:px-6">

View File

@ -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<Step>(1)
const [saving, setSaving] = useState(false)
const [resultStatus, setResultStatus] = useState<ResultStatus | null>(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<Question[]>([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<HTMLInputElement>) => {
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<Question>) =>
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 (
<div className="mx-auto w-full max-w-7xl px-4 pb-12 sm:px-6 lg:px-8">
<div className="space-y-5 sm:space-y-6">
{currentStep !== 4 ? (
<>
<Link
to={backTo}
className="group inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-grayScale-600 transition-colors hover:bg-brand-50 hover:text-brand-600"
>
<ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
Back to Sub-course
</Link>
<div className="border-b border-grayScale-100 pb-6 sm:pb-8">
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-3xl">Add New Lesson</h1>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-grayScale-500 sm:text-[15px]">
Create a lesson backed by `question_sets` and attach it through `sub_module_lessons`.
</p>
</div>
<div className="flex items-center justify-center rounded-2xl border border-grayScale-100 bg-grayScale-50/40 px-3 py-4 sm:px-6 sm:py-5">
{STEPS.map((step, index) => (
<div key={step.number} className="flex items-center">
<div className="flex flex-col items-center">
<div
className={`flex h-9 w-9 items-center justify-center rounded-full text-xs font-semibold shadow-sm transition-all duration-300 sm:h-10 sm:w-10 sm:text-sm ${
currentStep === step.number
? "bg-brand-500 text-white ring-4 ring-brand-100"
: currentStep > step.number
? "bg-brand-500 text-white"
: "border-2 border-grayScale-300 bg-white text-grayScale-400"
}`}
>
{currentStep > step.number ? <Check className="h-4 w-4" /> : step.number}
</div>
<span className="mt-2 text-xs font-semibold text-grayScale-500">{step.label}</span>
</div>
{index < STEPS.length - 1 ? (
<div className={`mx-4 h-0.5 w-20 ${currentStep > step.number ? "bg-brand-500" : "bg-grayScale-200"}`} />
) : null}
</div>
))}
</div>
</>
) : null}
{currentStep === 1 ? (
<Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
<div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 sm:px-8 sm:py-6">
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 1: Context</h2>
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
Define lesson metadata that will be stored in the linked question set.
</p>
</div>
<div className="p-5 sm:p-8 lg:p-10">
<div className="mt-5 grid gap-8 lg:grid-cols-12">
<div className="space-y-4 lg:col-span-7">
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Lesson title</label>
<Input
value={lessonTitle}
onChange={(e) => setLessonTitle(e.target.value)}
placeholder="Enter lesson title"
className="h-11"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Description</label>
<textarea
value={lessonDescription}
onChange={(e) => setLessonDescription(e.target.value)}
className="min-h-[96px] w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-grayScale-400 focus:outline-none focus:ring-2 focus:ring-grayScale-100"
placeholder="Enter lesson description"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Intro video URL (optional)</label>
<Input
value={introVideoUrl}
onChange={(e) => setIntroVideoUrl(e.target.value)}
onBlur={() => void handleIntroVideoUrlBlur()}
placeholder="https://..."
type="url"
inputMode="url"
autoComplete="off"
className="font-mono text-[13px]"
/>
<div className="flex flex-wrap items-center gap-2">
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-grayScale-200 px-3 py-2 text-xs text-grayScale-700 hover:bg-grayScale-50">
{uploadingIntroVideo ? <SpinnerIcon className="h-4 w-4" alt="" /> : <Upload className="h-4 w-4" />}
{uploadingIntroVideo ? "Uploading..." : "Upload video from computer"}
<input
type="file"
accept="video/*"
className="hidden"
onChange={handleIntroVideoFileChange}
disabled={uploadingIntroVideo}
/>
</label>
{introVideoUrl.trim() ? (
<Button type="button" variant="ghost" size="sm" onClick={() => setIntroVideoUrl("")}>
Clear URL
</Button>
) : null}
</div>
{introVideoPreview ? (
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50/40 p-3">
<p className="mb-2 text-xs font-medium text-grayScale-500">Preview</p>
{introVideoPreview.kind === "vimeo" ? (
<div className="overflow-hidden rounded-lg border border-grayScale-200 bg-black">
<iframe
src={introVideoPreview.url}
title="Intro video preview"
className="aspect-video w-full"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
/>
</div>
) : (
<video
controls
src={introVideoPreview.url}
className="aspect-video w-full rounded-lg border border-grayScale-200 bg-black"
/>
)}
</div>
) : null}
</div>
</div>
<aside className="space-y-4 lg:col-span-5">
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50/40 p-5 shadow-sm">
<h3 className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Lesson schema mapping</h3>
<div className="mt-3 space-y-2 text-sm text-grayScale-700">
<p>
<span className="font-medium">question_sets.title</span> Lesson title
</p>
<p>
<span className="font-medium">question_sets.description</span> Description
</p>
<p>
<span className="font-medium">question_sets.set_type</span> = QUIZ
</p>
<p>
<span className="font-medium">sub_module_lessons.intro_video_url</span> Intro URL
</p>
</div>
</div>
</aside>
</div>
</div>
<div className="flex flex-col-reverse items-stretch justify-between gap-3 border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4 sm:flex-row sm:items-center sm:px-8 sm:py-5">
<Button variant="ghost" onClick={() => navigate(backTo)} className="sm:w-auto">
Cancel
</Button>
<Button onClick={handleNext}>
Next: Questions
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</Card>
) : null}
{currentStep === 2 ? (
<div className="space-y-5">
{questions.map((question, index) => (
<Card key={question.id} className="border border-grayScale-200/90 border-l-4 border-l-grayScale-700 p-5 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-grayScale-400" />
<span className="font-semibold">Question {index + 1}</span>
</div>
<button type="button" onClick={() => removeQuestion(question.id)} className="text-grayScale-400 hover:text-red-500">
<Trash2 className="h-4 w-4" />
</button>
</div>
<PracticeQuestionEditorFields
value={{
questionText: question.questionText,
questionType: question.questionType,
difficultyLevel: question.difficultyLevel,
points: question.points,
tips: question.tips,
explanation: question.explanation,
options: question.options,
voicePrompt: question.voicePrompt,
sampleAnswerVoicePrompt: question.sampleAnswerVoicePrompt,
audioCorrectAnswerText: question.audioCorrectAnswerText,
shortAnswer: question.shortAnswers[0] ?? "",
imageUrl: question.imageUrl,
}}
onChange={(next) =>
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}
/>
</Card>
))}
<Button variant="outline" onClick={addQuestion} className="w-full border-dashed">
<Plus className="mr-2 h-4 w-4" />
Add another question
</Button>
<div className="flex items-center justify-between rounded-2xl border border-grayScale-200/80 bg-grayScale-50/30 px-4 py-4 sm:px-6 sm:py-5">
<Button variant="outline" onClick={handleBack}>
Back
</Button>
<Button onClick={handleNext}>
Next: Review
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
) : null}
{currentStep === 3 ? (
<Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
<div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 sm:px-8 sm:py-6">
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 3: Review & publish</h2>
<p className="mt-1.5 text-sm text-grayScale-500">Confirm lesson details and questions before saving or publishing.</p>
</div>
<div className="space-y-4 p-5 sm:p-8">
<div className="grid gap-4 lg:grid-cols-2">
<div className="overflow-hidden rounded-2xl border border-grayScale-200 bg-white">
<div className="flex items-center justify-between border-b border-grayScale-100 px-4 py-3">
<h3 className="text-base font-semibold text-grayScale-900">Basic Information</h3>
<button
type="button"
className="text-sm font-medium text-brand-500 hover:text-brand-600"
onClick={() => setCurrentStep(1)}
>
Edit
</button>
</div>
<div className="divide-y divide-grayScale-100">
<div className="flex items-center justify-between px-4 py-3 text-sm">
<span className="text-grayScale-500">Title</span>
<span className="font-medium text-grayScale-800">{lessonTitle || "Untitled Lesson"}</span>
</div>
<div className="flex items-center justify-between px-4 py-3 text-sm">
<span className="text-grayScale-500">Description</span>
<span className="max-w-[55%] truncate text-right font-medium text-grayScale-800">
{lessonDescription || "—"}
</span>
</div>
<div className="flex items-center justify-between px-4 py-3 text-sm">
<span className="text-grayScale-500">Intro video URL</span>
<span className="max-w-[55%] truncate text-right font-medium text-grayScale-800">{introVideoUrl || "—"}</span>
</div>
<div className="flex items-center justify-between px-4 py-3 text-sm">
<span className="text-grayScale-500">Sub-module</span>
<span className="font-medium text-grayScale-800">{subModuleId ?? "—"}</span>
</div>
</div>
</div>
<div className="overflow-hidden rounded-2xl border border-grayScale-200 bg-white">
<div className="flex items-center justify-between border-b border-grayScale-100 px-4 py-3">
<h3 className="text-base font-semibold text-grayScale-900">
Questions
<span className="ml-2 rounded-full bg-brand-100 px-2 py-0.5 text-xs font-semibold text-brand-700">
{reviewQuestions.length}
</span>
</h3>
<button
type="button"
className="text-sm font-medium text-brand-500 hover:text-brand-600"
onClick={() => setCurrentStep(2)}
>
Edit
</button>
</div>
<div className="space-y-3 p-3">
{reviewQuestions.length === 0 ? (
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 p-4 text-sm text-grayScale-500">
No question content added yet.
</div>
) : (
reviewQuestions.map((question, idx) => (
<div key={question.id} className="rounded-xl border border-grayScale-200 bg-grayScale-50/35 p-3">
<div className="mb-2 flex items-center gap-2">
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-brand-100 px-1.5 text-[11px] font-semibold text-brand-700">
{idx + 1}
</span>
<span className="rounded-md bg-indigo-50 px-2 py-0.5 text-[11px] font-semibold text-indigo-700">
{questionTypeLabel(question.questionType)}
</span>
<span className="rounded-md bg-grayScale-100 px-2 py-0.5 text-[11px] font-semibold text-grayScale-600">
{question.difficultyLevel}
</span>
<span className="text-[11px] font-semibold text-grayScale-500">{question.points} pt</span>
</div>
<p className="mb-2 line-clamp-2 text-sm font-medium text-grayScale-800">
{question.questionText.trim() || `Question ${idx + 1}`}
</p>
{question.questionType === "MCQ" ? (
<div className="space-y-1">
{question.options.map((option, optionIdx) => (
<div
key={`${question.id}-option-${optionIdx}`}
className={`rounded px-2 py-1 text-xs ${
option.isCorrect
? "bg-green-50 font-medium text-green-700"
: "text-grayScale-500"
}`}
>
{option.text || `Option ${optionIdx + 1}`}
</div>
))}
</div>
) : null}
</div>
))
)}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4 sm:px-8 sm:py-5">
<Button variant="outline" onClick={handleBack}>
Back
</Button>
<div className="flex gap-3">
<Button variant="outline" onClick={() => void saveLesson("DRAFT")} disabled={saving}>
{saving ? "Saving..." : "Save as Draft"}
</Button>
<Button onClick={() => void saveLesson("PUBLISHED")} disabled={saving}>
<Rocket className="mr-2 h-4 w-4" />
{saving ? "Publishing..." : "Publish Now"}
</Button>
</div>
</div>
</Card>
) : null}
{currentStep === 4 && resultStatus ? (
<div className="mx-auto flex max-w-xl flex-col items-center py-16 text-center">
<div className={`mb-5 grid h-24 w-24 place-items-center rounded-full ${resultStatus === "success" ? "bg-gradient-to-br from-brand-200 to-brand-400" : "bg-gradient-to-br from-red-200 to-red-400"}`}>
<Check className="h-10 w-10 text-white" />
</div>
<h2 className="text-4xl font-bold tracking-tight text-grayScale-900">
{resultStatus === "success" ? "Lesson Published Successfully!" : "Lesson save failed"}
</h2>
<p className="mt-3 text-sm text-grayScale-500">{resultStatus === "success" ? "Your lesson is now active." : resultMessage}</p>
<div className="mt-8 w-full space-y-3">
<Button
className="h-11 w-full text-base"
onClick={() =>
navigate(lastSavedStatus === "PUBLISHED" ? "/content/human-language" : backTo)
}
>
Go back to Course
</Button>
{resultStatus === "success" ? (
<Button variant="outline" className="h-11 w-full text-base" onClick={() => navigate(0)}>
Add Another Lesson
</Button>
) : (
<Button variant="outline" className="h-11 w-full text-base" onClick={() => setCurrentStep(3)}>
Back to Review
</Button>
)}
</div>
</div>
) : null}
</div>
</div>
)
}

View File

@ -1,6 +1,6 @@
import { useMemo, useRef, useState, type ChangeEvent } from "react" import { useMemo, useRef, useState, type ChangeEvent } from "react"
import { Link, useLocation, useParams, useNavigate } from "react-router-dom" 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 { toast } from "sonner"
import { Card } from "../../components/ui/card" import { Card } from "../../components/ui/card"
import { Button } from "../../components/ui/button" 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 { createQuestionSet, createQuestion, addQuestionToSet } from "../../api/courses.api"
import { uploadVideoFile } from "../../api/files.api" import { uploadVideoFile } from "../../api/files.api"
import { Select } from "../../components/ui/select" import { Select } from "../../components/ui/select"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import type { QuestionOption } from "../../types/course.types" import type { QuestionOption } from "../../types/course.types"
type Step = 1 | 2 | 3 | 4 | 5 type Step = 1 | 2 | 3 | 4 | 5
@ -526,7 +527,7 @@ export function AddNewPracticePage() {
className="gap-1.5" className="gap-1.5"
> >
{uploadingIntroVideo ? ( {uploadingIntroVideo ? (
<Loader2 className="h-4 w-4 animate-spin" /> <SpinnerIcon className="h-4 w-4" alt="" />
) : ( ) : (
<Upload className="h-4 w-4" /> <Upload className="h-4 w-4" />
)} )}
@ -541,7 +542,7 @@ export function AddNewPracticePage() {
> >
{importingIntroVideoUrl ? ( {importingIntroVideoUrl ? (
<> <>
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> <SpinnerIcon className="mr-1.5 h-4 w-4" alt="" />
Importing URL Importing URL
</> </>
) : ( ) : (

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Link } from "react-router-dom" 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 spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import alertSrc from "../../assets/Alert.svg" import alertSrc from "../../assets/Alert.svg"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
@ -11,10 +11,11 @@ import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "../../components/ui/dialog" } 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 type { CourseCategory } from "../../types/course.types"
import { toast } from "sonner" import { toast } from "sonner"
@ -29,6 +30,8 @@ export function CourseCategoryPage() {
const [newSubCategoryName, setNewSubCategoryName] = useState("") const [newSubCategoryName, setNewSubCategoryName] = useState("")
const [pendingSubCategories, setPendingSubCategories] = useState<string[]>([]) const [pendingSubCategories, setPendingSubCategories] = useState<string[]>([])
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
const [deleteTarget, setDeleteTarget] = useState<CourseCategory | null>(null)
const [deleting, setDeleting] = useState(false)
const fetchCategories = async () => { const fetchCategories = async () => {
setLoading(true) setLoading(true)
@ -164,12 +167,26 @@ export function CourseCategoryPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center justify-between gap-2">
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600"> <span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
View Sub-categories View Sub-categories
<span className="inline-block transition-transform duration-300 group-hover:translate-x-1"> <span className="inline-block transition-transform duration-300 group-hover:translate-x-1">
</span> </span>
</span> </span>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-red-200 bg-white text-red-500 hover:bg-red-50"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setDeleteTarget(category)
}}
aria-label={`Delete category ${category.name}`}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
@ -335,7 +352,7 @@ export function CourseCategoryPage() {
if (createdCategoryId && pendingSubCategories.length > 0) { if (createdCategoryId && pendingSubCategories.length > 0) {
await Promise.all( await Promise.all(
pendingSubCategories.map((subName) => pendingSubCategories.map((subName) =>
createCourseCategory({ name: subName }), createCourseCategory({ name: subName, parent_id: createdCategoryId }),
), ),
) )
} }
@ -371,6 +388,46 @@ export function CourseCategoryPage() {
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete category?</DialogTitle>
<DialogDescription>
{deleteTarget
? `This will permanently delete "${deleteTarget.name}" and all linked sub-categories/courses.`
: ""}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDeleteTarget(null)} disabled={deleting}>
Cancel
</Button>
<Button
type="button"
className="bg-red-600 text-white hover:bg-red-700"
disabled={deleting}
onClick={async () => {
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"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
) )
} }

View File

@ -1,6 +1,5 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { import {
BadgeCheck,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
GripVertical, GripVertical,
@ -32,9 +31,9 @@ import { Badge } from "../../components/ui/badge"
import { import {
getCourseCategories, getCourseCategories,
getCoursesByCategory, getCoursesByCategory,
getLearningPath, getSubModulesByCourse,
getVideosBySubModule,
getQuestionSetsByOwner, getQuestionSetsByOwner,
getSubModuleEntryAssessment,
reorderCategories, reorderCategories,
reorderCourses, reorderCourses,
reorderSubModules, reorderSubModules,
@ -194,9 +193,7 @@ export function CourseFlowBuilderPage() {
const [practicesBySubCourse, setPracticesBySubCourse] = useState<Record<number, PracticeListItem[]>>( const [practicesBySubCourse, setPracticesBySubCourse] = useState<Record<number, PracticeListItem[]>>(
{}, {},
) )
const [entryAssessmentBySubCourse, setEntryAssessmentBySubCourse] = useState<Record<number, QuestionSet | null>>( const [videosBySubCourse, setVideosBySubCourse] = useState<Record<number, LearningPathVideo[]>>({})
{},
)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [loadingCourses, setLoadingCourses] = useState(false) const [loadingCourses, setLoadingCourses] = useState(false)
@ -260,7 +257,9 @@ export function CourseFlowBuilderPage() {
setLoadingCourses(true) setLoadingCourses(true)
try { try {
const res = await getCoursesByCategory(selectedCategoryId) 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 })) setCoursesByCategory((prev) => ({ ...prev, [selectedCategoryId]: items }))
setSelectedCourseId(items[0]?.id ?? null) setSelectedCourseId(items[0]?.id ?? null)
} catch { } catch {
@ -280,47 +279,94 @@ export function CourseFlowBuilderPage() {
const load = async () => { const load = async () => {
setLoadingPath(true) setLoadingPath(true)
try { try {
const res = await getLearningPath(selectedCourseId) const selectedCourse = activeCourses.find((course) => course.id === selectedCourseId)
const path = res.data.data 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({ setLearningPath({
...path, course_id: selectedCourseId,
sub_courses: sortByDisplayOrder(path.sub_courses ?? []), 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. if (subCourses.length === 0) {
const subCourses = path.sub_courses ?? [] setPracticesBySubCourse({})
if (subCourses.length > 0) { setVideosBySubCourse({})
const ownerResults = await Promise.all( return
}
const [ownerResults, videoResults] = await Promise.all([
Promise.all(
subCourses.map(async (sc) => { 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 return [sc.id, mapPracticeSetsToPracticeItems((setsRes.data.data ?? []) as QuestionSet[])] as const
}), }),
),
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<number, PracticeListItem[]> = {} const practiceMap: Record<number, PracticeListItem[]> = {}
ownerResults.forEach(([subCourseId, practiceItems]) => { ownerResults.forEach(([subCourseId, practiceItems]) => {
practiceMap[subCourseId] = practiceItems practiceMap[subCourseId] = practiceItems
}) })
setPracticesBySubCourse(practiceMap) setPracticesBySubCourse(practiceMap)
} else {
setPracticesBySubCourse({}) const videoMap: Record<number, LearningPathVideo[]> = {}
} videoResults.forEach(([subCourseId, videos]) => {
videoMap[subCourseId] = videos
})
setVideosBySubCourse(videoMap)
} catch { } catch {
toast.error("Failed to load course sub-category learning path.") toast.error("Failed to load course flow detail.")
setLearningPath(null) setLearningPath(null)
} finally { } finally {
setLoadingPath(false) setLoadingPath(false)
} }
} }
load() load()
}, [selectedCourseId]) }, [selectedCourseId, activeCourses, selectedCategoryId, topLevelCategories])
const loadSubCoursePracticeAndEntry = async (subCourseId: number) => { const loadSubCoursePracticeAndEntry = async (subCourseId: number) => {
if (practicesBySubCourse[subCourseId] && entryAssessmentBySubCourse[subCourseId] !== undefined) return if (practicesBySubCourse[subCourseId] && videosBySubCourse[subCourseId]) return
setLoadingPracticesBySubCourse((prev) => ({ ...prev, [subCourseId]: true })) setLoadingPracticesBySubCourse((prev) => ({ ...prev, [subCourseId]: true }))
try { try {
const [setsRes, entryRes] = await Promise.allSettled([ const [setsRes, videosRes] = await Promise.allSettled([
getQuestionSetsByOwner("SUB_COURSE", subCourseId), getQuestionSetsByOwner("SUB_MODULE", subCourseId),
getSubModuleEntryAssessment(subCourseId), getVideosBySubModule(subCourseId),
]) ])
// No practice sets is a valid empty-state scenario; do not toast for 404/empty. // 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), [subCourseId]: mapPracticeSetsToPracticeItems(ownerSets),
})) }))
// Entry assessment may legitimately be absent. const videos =
let entryAssessment: QuestionSet | null = null videosRes.status === "fulfilled"
if (entryRes.status === "fulfilled") { ? sortByDisplayOrder(
entryAssessment = (entryRes.value.data.data ?? null) as QuestionSet | null (videosRes.value.data?.data?.videos ?? []).map((video: any, idx: number) => ({
} else { id: Number(video.id),
const status = entryRes.reason?.response?.status title: String(video.title ?? "Video"),
if (status !== 404) { display_order: Number(video.display_order ?? idx),
throw entryRes.reason duration: Number(video.duration ?? 0),
} video_url: String(video.video_url ?? ""),
} })),
)
setEntryAssessmentBySubCourse((prev) => ({ : []
setVideosBySubCourse((prev) => ({
...prev, ...prev,
[subCourseId]: entryAssessment, [subCourseId]: videos,
})) }))
} catch { } catch {
toast.error("Failed to load practice sets for course.") toast.error("Failed to load practice sets for course.")
@ -694,6 +741,7 @@ export function CourseFlowBuilderPage() {
{learningPath.sub_courses.map((subCourse) => { {learningPath.sub_courses.map((subCourse) => {
const expanded = expandedSubCourseIds.has(subCourse.id) const expanded = expandedSubCourseIds.has(subCourse.id)
const practices = practicesBySubCourse[subCourse.id] ?? [] const practices = practicesBySubCourse[subCourse.id] ?? []
const videos = videosBySubCourse[subCourse.id] ?? subCourse.videos ?? []
return ( return (
<SortableRow key={subCourse.id} id={subCourse.id}> <SortableRow key={subCourse.id} id={subCourse.id}>
<button <button
@ -723,17 +771,12 @@ export function CourseFlowBuilderPage() {
{subCourse.sub_level} {subCourse.sub_level}
</Badge> </Badge>
)} )}
{entryAssessmentBySubCourse[subCourse.id] && ( {/* entry-assessment route is no longer guaranteed across deployments */}
<span className="inline-flex items-center gap-1 text-emerald-600">
<BadgeCheck className="h-3.5 w-3.5" />
Entry assessment
</span>
)}
</p> </p>
</div> </div>
<div className="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-end"> <div className="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-end">
<Badge variant="secondary" className="text-[10px]"> <Badge variant="secondary" className="text-[10px]">
{subCourse.videos.length} videos / {practices.length || subCourse.practice_count} practices {videos.length} videos / {practices.length} practices
</Badge> </Badge>
{expanded ? ( {expanded ? (
<ChevronDown className="h-4 w-4 text-grayScale-400" /> <ChevronDown className="h-4 w-4 text-grayScale-400" />
@ -755,16 +798,16 @@ export function CourseFlowBuilderPage() {
onDragEnd={(event) => onVideosDragEnd(subCourse.id, event)} onDragEnd={(event) => onVideosDragEnd(subCourse.id, event)}
> >
<SortableContext <SortableContext
items={subCourse.videos.map((item) => item.id)} items={videos.map((item) => item.id)}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<div className="space-y-1.5"> <div className="space-y-1.5">
{subCourse.videos.length === 0 ? ( {videos.length === 0 ? (
<p className="rounded-lg border border-dashed border-grayScale-200 px-2 py-2 text-[11px] text-grayScale-400"> <p className="rounded-lg border border-dashed border-grayScale-200 px-2 py-2 text-[11px] text-grayScale-400">
No videos No videos
</p> </p>
) : ( ) : (
subCourse.videos.map((video) => ( videos.map((video) => (
<SortableChip <SortableChip
key={video.id} key={video.id}
id={video.id} id={video.id}
@ -842,7 +885,7 @@ export function CourseFlowBuilderPage() {
</p> </p>
<p> <p>
Practices load from <code>/question-sets/by-owner</code> filtered by Practices load from <code>/question-sets/by-owner</code> filtered by
<code> set_type=PRACTICE</code>; entry assessment loads from dedicated course endpoint. <code> set_type=PRACTICE</code> and <code>owner_type=SUB_MODULE</code>.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -1,14 +1,27 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { Link, useParams, useNavigate } from "react-router-dom" 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 practiceSrc from "../../assets/Practice.svg"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.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 alertSrc from "../../assets/Alert.svg"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Badge } from "../../components/ui/badge" import { Badge } from "../../components/ui/badge"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { FileUpload } from "../../components/ui/file-upload"
import { import {
Table, Table,
TableBody, TableBody,
@ -18,24 +31,19 @@ import {
TableRow, TableRow,
} from "../../components/ui/table" } from "../../components/ui/table"
import { import {
getCoursesByCategory, getSubCategoriesByCategoryId,
getCourseCategories, getCourseCategories,
createCourse, createSubCategory,
deleteCourse, deleteCourseSubCategory,
updateCourseStatus, updateSubCategory,
updateCourse,
updateCourseThumbnail,
getRatings,
} from "../../api/courses.api" } from "../../api/courses.api"
import { uploadImageFile } from "../../api/files.api" import type { CategorySubCategoryListItem, CourseCategory } from "../../types/course.types"
import type { Course, CourseCategory, Rating } from "../../types/course.types"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
export function CoursesPage() { export function CoursesPage() {
const { categoryId } = useParams<{ categoryId: string }>() const { categoryId } = useParams<{ categoryId: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const [courses, setCourses] = useState<Course[]>([]) const [subCategories, setSubCategories] = useState<CategorySubCategoryListItem[]>([])
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
const [category, setCategory] = useState<CourseCategory | null>(null) const [category, setCategory] = useState<CourseCategory | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -44,36 +52,32 @@ export function CoursesPage() {
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
const [title, setTitle] = useState("") const [title, setTitle] = useState("")
const [description, setDescription] = useState("") const [description, setDescription] = useState("")
const [displayOrder, setDisplayOrder] = useState("")
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null) const [saveError, setSaveError] = useState<string | null>(null)
const [showDeleteModal, setShowDeleteModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false)
const [courseToDelete, setCourseToDelete] = useState<Course | null>(null) const [subCategoryToDelete, setSubCategoryToDelete] = useState<CategorySubCategoryListItem | null>(null)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null) const [togglingId, setTogglingId] = useState<number | null>(null)
const [showEditModal, setShowEditModal] = useState(false) const [showEditModal, setShowEditModal] = useState(false)
const [courseToEdit, setCourseToEdit] = useState<Course | null>(null) const [subCategoryToEdit, setSubCategoryToEdit] = useState<CategorySubCategoryListItem | null>(null)
const [editTitle, setEditTitle] = useState("") const [editTitle, setEditTitle] = useState("")
const [editDescription, setEditDescription] = useState("") const [editDescription, setEditDescription] = useState("")
const [editThumbnail, setEditThumbnail] = useState("") const [editDisplayOrder, setEditDisplayOrder] = useState(0)
const [editThumbnailFile, setEditThumbnailFile] = useState<File | null>(null)
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
const [updateError, setUpdateError] = useState<string | null>(null) const [updateError, setUpdateError] = useState<string | null>(null)
const [showRatingsModal, setShowRatingsModal] = useState(false)
const [ratingsCourseId, setRatingsCourseId] = useState<number | null>(null)
const [courseRatings, setCourseRatings] = useState<Rating[]>([])
const [courseRatingsLoading, setCourseRatingsLoading] = useState(false)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10) const [pageSize, setPageSize] = useState(10)
const fetchCourses = async () => { const fetchSubCategories = async () => {
if (!categoryId) return if (!categoryId) return
try { try {
const coursesRes = await getCoursesByCategory(Number(categoryId)) const res = await getSubCategoriesByCategoryId(Number(categoryId))
console.log("Courses response:", coursesRes.data.data.courses) const raw = res.data?.data?.sub_categories
setCourses(coursesRes.data.data.courses ?? []) setSubCategories(Array.isArray(raw) ? raw : [])
} catch (err) { } 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 if (!categoryId) return
try { try {
const [coursesRes, categoriesRes] = await Promise.all([ const [subRes, categoriesRes] = await Promise.all([
getCoursesByCategory(Number(categoryId)), getSubCategoriesByCategoryId(Number(categoryId)),
getCourseCategories(), getCourseCategories(),
]) ])
setCourses(coursesRes.data.data.courses ?? []) const raw = subRes.data?.data?.sub_categories
const foundCategory = categoriesRes.data.data.categories.find( setSubCategories(Array.isArray(raw) ? raw : [])
(c) => c.id === Number(categoryId) const foundCategory = categoriesRes.data?.data?.categories?.find(
(c) => c.id === Number(categoryId),
) )
setCategory(foundCategory ?? null) setCategory(foundCategory ?? null)
} catch (err) { } catch (err) {
console.error("Failed to fetch courses:", err) console.error("Failed to fetch sub-categories:", err)
setError("Failed to load sub-categories") setError("Failed to load sub-categories")
} finally { } finally {
setLoading(false) setLoading(false)
@ -110,6 +115,7 @@ export function CoursesPage() {
const handleOpenModal = () => { const handleOpenModal = () => {
setTitle("") setTitle("")
setDescription("") setDescription("")
setDisplayOrder("")
setSaveError(null) setSaveError(null)
setShowModal(true) setShowModal(true)
} }
@ -118,16 +124,13 @@ export function CoursesPage() {
setShowModal(false) setShowModal(false)
setTitle("") setTitle("")
setDescription("") setDescription("")
setDisplayOrder("")
setSaveError(null) setSaveError(null)
} }
const handleSave = async () => { const handleSave = async () => {
if (!title.trim()) { if (!title.trim()) {
setSaveError("Title is required") setSaveError("Name is required")
return
}
if (!description.trim()) {
setSaveError("Description is required")
return return
} }
@ -135,13 +138,15 @@ export function CoursesPage() {
setSaveError(null) setSaveError(null)
try { try {
await createCourse({ const orderParsed = parseInt(displayOrder.trim(), 10)
await createSubCategory({
category_id: Number(categoryId), category_id: Number(categoryId),
title: title.trim(), name: title.trim(),
description: description.trim(), description: description.trim() || null,
...(Number.isFinite(orderParsed) && orderParsed >= 0 ? { display_order: orderParsed } : {}),
}) })
handleCloseModal() handleCloseModal()
await fetchCourses() await fetchSubCategories()
} catch (err: any) { } catch (err: any) {
console.error("Failed to create course:", err) console.error("Failed to create course:", err)
setSaveError(err.response?.data?.message || "Failed to create sub-category") setSaveError(err.response?.data?.message || "Failed to create sub-category")
@ -150,20 +155,20 @@ export function CoursesPage() {
} }
} }
const handleDeleteClick = (course: Course) => { const handleDeleteClick = (sub: CategorySubCategoryListItem) => {
setCourseToDelete(course) setSubCategoryToDelete(sub)
setShowDeleteModal(true) setShowDeleteModal(true)
} }
const handleConfirmDelete = async () => { const handleConfirmDelete = async () => {
if (!courseToDelete) return if (!subCategoryToDelete) return
setDeleting(true) setDeleting(true)
try { try {
await deleteCourse(courseToDelete.id) await deleteCourseSubCategory(subCategoryToDelete.id)
setShowDeleteModal(false) setShowDeleteModal(false)
setCourseToDelete(null) setSubCategoryToDelete(null)
await fetchCourses() await fetchSubCategories()
} catch (err) { } catch (err) {
console.error("Failed to delete course:", err) console.error("Failed to delete course:", err)
} finally { } finally {
@ -171,11 +176,11 @@ export function CoursesPage() {
} }
} }
const handleToggleStatus = async (course: Course) => { const handleToggleStatus = async (sub: CategorySubCategoryListItem) => {
setTogglingId(course.id) setTogglingId(sub.id)
try { try {
await updateCourseStatus(course.id, !course.is_active) await updateSubCategory(sub.id, { is_active: !sub.is_active })
await fetchCourses() await fetchSubCategories()
} catch (err) { } catch (err) {
console.error("Failed to update course status:", err) console.error("Failed to update course status:", err)
} finally { } finally {
@ -183,35 +188,29 @@ export function CoursesPage() {
} }
} }
const handleEditClick = (course: Course) => { const handleEditClick = (sub: CategorySubCategoryListItem) => {
setCourseToEdit(course) setSubCategoryToEdit(sub)
setEditTitle(course.title || "") setEditTitle(sub.name || "")
setEditDescription(course.description || "") setEditDescription(sub.description ?? "")
setEditThumbnail(course.thumbnail || "") setEditDisplayOrder(sub.display_order ?? 0)
setEditThumbnailFile(null)
setUpdateError(null) setUpdateError(null)
setShowEditModal(true) setShowEditModal(true)
} }
const handleCloseEditModal = () => { const handleCloseEditModal = () => {
setShowEditModal(false) setShowEditModal(false)
setCourseToEdit(null) setSubCategoryToEdit(null)
setEditTitle("") setEditTitle("")
setEditDescription("") setEditDescription("")
setEditThumbnail("") setEditDisplayOrder(0)
setEditThumbnailFile(null)
setUpdateError(null) setUpdateError(null)
} }
const handleUpdate = async () => { const handleUpdate = async () => {
if (!courseToEdit) return if (!subCategoryToEdit) return
if (!editTitle.trim()) { if (!editTitle.trim()) {
setUpdateError("Title is required") setUpdateError("Name is required")
return
}
if (!editDescription.trim()) {
setUpdateError("Description is required")
return return
} }
@ -219,23 +218,15 @@ export function CoursesPage() {
setUpdateError(null) setUpdateError(null)
try { try {
await updateCourse(courseToEdit.id, { await updateSubCategory(subCategoryToEdit.id, {
title: editTitle.trim(), name: editTitle.trim(),
description: editDescription.trim(), description: editDescription.trim() || null,
is_active: courseToEdit.is_active, 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() handleCloseEditModal()
await fetchCourses() await fetchSubCategories()
} catch (err: any) { } catch (err: any) {
console.error("Failed to update course:", err) console.error("Failed to update course:", err)
setUpdateError(err.response?.data?.message || "Failed to update sub-category") setUpdateError(err.response?.data?.message || "Failed to update sub-category")
@ -244,32 +235,19 @@ export function CoursesPage() {
} }
} }
const handleCourseClick = (courseId: number) => { const handleOpenSubCategory = (subCategoryId: number) => {
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-modules`) navigate(`/content/category/${categoryId}/sub-categories/${subCategoryId}/courses`)
} }
const handleViewRatings = async (courseId: number) => { const filteredSubCategories = useMemo(() => {
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 q = searchQuery.trim().toLowerCase() const q = searchQuery.trim().toLowerCase()
if (!q) return courses if (!q) return subCategories
return courses.filter((course) => { return subCategories.filter((sub) => {
const haystack = `${course.title} ${course.description ?? ""} ${course.id}`.toLowerCase() const haystack =
`${sub.name} ${sub.description ?? ""} ${sub.category_name} ${sub.id} ${sub.display_order}`.toLowerCase()
return haystack.includes(q) return haystack.includes(q)
}) })
}, [courses, searchQuery]) }, [subCategories, searchQuery])
if (loading) { if (loading) {
return ( 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 totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
const safePage = Math.min(page, totalPages) 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 startEntry = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1
const endEntry = Math.min(safePage * pageSize, totalCount) 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 getPageNumbers = () => {
const pages: (number | string)[] = [] const pages: (number | string)[] = []
if (totalPages <= 7) { if (totalPages <= 7) {
@ -328,7 +322,7 @@ export function CoursesPage() {
{category?.name} Sub-categories {category?.name} Sub-categories
</h1> </h1>
<p className="mt-0.5 text-sm text-grayScale-400"> <p className="mt-0.5 text-sm text-grayScale-400">
<span className="font-medium text-grayScale-500">{courses.length}</span> sub-categories available <span className="font-medium text-grayScale-500">{subCategories.length}</span> sub-categories available
</p> </p>
</div> </div>
</div> </div>
@ -339,15 +333,11 @@ export function CoursesPage() {
</div> </div>
</div> </div>
{/* Course table or empty state */} {/* Sub-category table — layout aligned with Activity Log (UserLogPage) */}
<Card className="shadow-soft"> <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-white p-4">
<CardHeader className="border-b border-grayScale-200 pb-3"> <h2 className="text-base font-semibold text-grayScale-600">Sub-category Management</h2>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="relative w-full min-w-[200px] flex-1 sm:max-w-xs sm:flex-initial">
<CardTitle className="text-base font-semibold text-grayScale-600"> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
Sub-category Management
</CardTitle>
<div className="relative w-full sm:max-w-xs">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-300" />
<Input <Input
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
@ -356,15 +346,13 @@ export function CoursesPage() {
/> />
</div> </div>
</div> </div>
</CardHeader>
<CardContent className="pt-4"> {subCategories.length === 0 ? (
{courses.length === 0 ? ( <div className="rounded-xl border bg-white">
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-16 text-center"> <div className="flex flex-col items-center justify-center px-6 py-16 text-center">
<img src={practiceSrc} alt="" className="h-16 w-16" /> <img src={practiceSrc} alt="" className="h-16 w-16" />
<h3 className="mt-4 text-base font-semibold text-grayScale-600">No sub-categories yet</h3> <h3 className="mt-4 text-base font-semibold text-grayScale-600">No sub-categories yet</h3>
<p className="mt-1.5 text-sm text-grayScale-400"> <p className="mt-1.5 text-sm text-grayScale-400">No sub-categories found in this category.</p>
No sub-categories found in this category.
</p>
<Button <Button
variant="outline" variant="outline"
className="mt-5 border-brand-200 text-brand-600 transition-colors hover:bg-brand-50" className="mt-5 border-brand-200 text-brand-600 transition-colors hover:bg-brand-50"
@ -374,60 +362,90 @@ export function CoursesPage() {
Add your first sub-category Add your first sub-category
</Button> </Button>
</div> </div>
) : filteredCourses.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-16 text-center">
<p className="text-base font-semibold text-grayScale-600">No matching sub-categories</p>
<p className="mt-1.5 text-sm text-grayScale-400">
Try a different search term.
</p>
</div> </div>
) : ( ) : (
<div className="rounded-xl border bg-white"> <div className="rounded-xl border bg-white">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Sub-category</TableHead> <TableHead className="w-[88px] whitespace-nowrap">ID</TableHead>
<TableHead className="hidden md:table-cell">Status</TableHead> <TableHead className="min-w-[160px]">SUB-CATEGORY</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="hidden lg:table-cell min-w-[140px]">CATEGORY</TableHead>
<TableHead className="min-w-[220px]">DESCRIPTION</TableHead>
<TableHead className="hidden xl:table-cell w-[100px] whitespace-nowrap">ORDER</TableHead>
<TableHead className="hidden md:table-cell whitespace-nowrap">CATEGORY ID</TableHead>
<TableHead className="hidden xl:table-cell min-w-[140px] whitespace-nowrap">CREATED</TableHead>
<TableHead className="whitespace-nowrap">STATUS</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{paginatedCourses.map((course) => ( {filteredSubCategories.length === 0 ? (
<TableRow <TableRow>
key={course.id} <TableCell colSpan={9} className="text-center py-12">
className="group cursor-pointer" <div className="flex flex-col items-center gap-3">
onClick={() => handleCourseClick(course.id)} <Search className="h-8 w-8 text-grayScale-200" />
> <div>
<TableCell className="max-w-md py-3.5"> <p className="text-sm font-medium text-grayScale-500">No matching sub-categories</p>
<div className="truncate text-sm font-semibold text-grayScale-700"> <p className="mt-1 text-xs text-grayScale-400">Try a different search term.</p>
{course.title}
</div> </div>
{course.description && (
<div className="mt-1 truncate text-xs text-grayScale-400">
{course.description}
</div> </div>
)}
</TableCell> </TableCell>
<TableCell className="hidden py-3.5 md:table-cell"> </TableRow>
) : (
paginatedSubCategories.map((sub) => (
<TableRow
key={sub.id}
className="group cursor-pointer"
onClick={() => handleOpenSubCategory(sub.id)}
>
<TableCell className="tabular-nums text-sm text-grayScale-500">{formatId(sub.id)}</TableCell>
<TableCell>
<div className="flex items-center gap-2.5">
<div className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400 transition-colors group-hover:bg-brand-500 group-hover:text-white">
<BookOpen className="h-4 w-4" />
</div>
<p className="min-w-0 text-sm font-medium text-grayScale-600">{sub.name}</p>
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
<span className="text-sm text-grayScale-600">{sub.category_name}</span>
</TableCell>
<TableCell>
<p className="max-w-md truncate text-sm text-grayScale-600" title={sub.description || undefined}>
{sub.description?.trim() ? sub.description : "—"}
</p>
</TableCell>
<TableCell className="hidden xl:table-cell tabular-nums text-sm text-grayScale-600">
{sub.display_order}
</TableCell>
<TableCell className="hidden md:table-cell">
<span className="text-sm tabular-nums text-grayScale-600">{formatId(sub.category_id)}</span>
</TableCell>
<TableCell className="hidden xl:table-cell text-sm text-grayScale-500">
{formatCreatedAt(sub.created_at)}
</TableCell>
<TableCell>
<Badge <Badge
variant={course.is_active ? "success" : "secondary"} variant={sub.is_active ? "success" : "secondary"}
className="text-[11px] font-semibold" className="text-[11px] font-semibold"
> >
{course.is_active ? "Active" : "Inactive"} {sub.is_active ? "Active" : "Inactive"}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="py-3.5 text-right"> <TableCell className="text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-amber-400 hover:bg-amber-50 hover:text-amber-500" className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleViewRatings(course.id) handleOpenSubCategory(sub.id)
}} }}
title="View courses"
> >
<Star className="h-4 w-4" /> <Eye className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@ -435,7 +453,7 @@ export function CoursesPage() {
className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700" className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleEditClick(course) handleEditClick(sub)
}} }}
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
@ -444,13 +462,13 @@ export function CoursesPage() {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700" className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700"
disabled={togglingId === course.id} disabled={togglingId === sub.id}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleToggleStatus(course) handleToggleStatus(sub)
}} }}
> >
{course.is_active ? ( {sub.is_active ? (
<ToggleLeft className="h-4 w-4" /> <ToggleLeft className="h-4 w-4" />
) : ( ) : (
<ToggleRight className="h-4 w-4" /> <ToggleRight className="h-4 w-4" />
@ -462,7 +480,7 @@ export function CoursesPage() {
className="h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-600" className="h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-600"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleDeleteClick(course) handleDeleteClick(sub)
}} }}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
@ -470,15 +488,17 @@ export function CoursesPage() {
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))
)}
</TableBody> </TableBody>
</Table> </Table>
{totalCount > 0 ? (
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500"> <div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span>Showing</span> <span>Showing</span>
<span className="font-medium text-grayScale-600"> <span className="font-medium text-grayScale-600">
{startEntry}-{endEntry} {startEntry}{endEntry}
</span> </span>
<span>of</span> <span>of</span>
<span className="font-medium text-grayScale-600">{totalCount}</span> <span className="font-medium text-grayScale-600">{totalCount}</span>
@ -499,11 +519,12 @@ export function CoursesPage() {
</option> </option>
))} ))}
</select> </select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" /> <ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
type="button"
onClick={() => safePage > 1 && setPage(safePage - 1)} onClick={() => safePage > 1 && setPage(safePage - 1)}
disabled={safePage === 1} disabled={safePage === 1}
className={cn( className={cn(
@ -535,6 +556,7 @@ export function CoursesPage() {
), ),
)} )}
<button <button
type="button"
onClick={() => safePage < totalPages && setPage(safePage + 1)} onClick={() => safePage < totalPages && setPage(safePage + 1)}
disabled={safePage === totalPages} disabled={safePage === totalPages}
className={cn( className={cn(
@ -546,12 +568,11 @@ export function CoursesPage() {
</button> </button>
</div> </div>
</div> </div>
) : null}
</div> </div>
)} )}
</CardContent>
</Card>
{/* Add Course Modal */} {/* Add Sub-category Modal */}
{showModal && ( {showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-2xl animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl"> <div className="mx-4 w-full max-w-2xl animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
@ -575,14 +596,14 @@ export function CoursesPage() {
<div> <div>
<label <label
htmlFor="course-title" htmlFor="subcat-name"
className="mb-2 block text-sm font-medium text-grayScale-600" className="mb-2 block text-sm font-medium text-grayScale-600"
> >
Title <span className="text-red-400">*</span> Name <span className="text-red-400">*</span>
</label> </label>
<Input <Input
id="course-title" id="subcat-name"
placeholder="Enter sub-category title" placeholder="Enter sub-category name"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
/> />
@ -590,14 +611,14 @@ export function CoursesPage() {
<div> <div>
<label <label
htmlFor="course-description" htmlFor="subcat-description"
className="mb-2 block text-sm font-medium text-grayScale-600" className="mb-2 block text-sm font-medium text-grayScale-600"
> >
Description <span className="text-red-400">*</span> Description
</label> </label>
<textarea <textarea
id="course-description" id="subcat-description"
placeholder="Enter sub-category description" placeholder="Optional description"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
rows={4} rows={4}
@ -605,6 +626,20 @@ export function CoursesPage() {
/> />
</div> </div>
<div>
<label htmlFor="subcat-order" className="mb-2 block text-sm font-medium text-grayScale-600">
Display order
</label>
<Input
id="subcat-order"
type="number"
min={0}
placeholder="0"
value={displayOrder}
onChange={(e) => setDisplayOrder(e.target.value)}
/>
</div>
<div className="rounded-lg bg-grayScale-50 px-3 py-2 text-xs text-grayScale-400"> <div className="rounded-lg bg-grayScale-50 px-3 py-2 text-xs text-grayScale-400">
Category: <span className="font-semibold text-grayScale-600">{category?.name}</span> Category: <span className="font-semibold text-grayScale-600">{category?.name}</span>
</div> </div>
@ -626,8 +661,8 @@ export function CoursesPage() {
</div> </div>
)} )}
{/* Edit Course Modal */} {/* Edit Sub-category Modal */}
{showEditModal && courseToEdit && ( {showEditModal && subCategoryToEdit && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-md animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl"> <div className="mx-4 w-full max-w-md animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
@ -650,14 +685,14 @@ export function CoursesPage() {
<div> <div>
<label <label
htmlFor="edit-course-title" htmlFor="edit-subcat-name"
className="mb-2 block text-sm font-medium text-grayScale-600" className="mb-2 block text-sm font-medium text-grayScale-600"
> >
Title <span className="text-red-400">*</span> Name <span className="text-red-400">*</span>
</label> </label>
<Input <Input
id="edit-course-title" id="edit-subcat-name"
placeholder="Enter sub-category title" placeholder="Enter sub-category name"
value={editTitle} value={editTitle}
onChange={(e) => setEditTitle(e.target.value)} onChange={(e) => setEditTitle(e.target.value)}
/> />
@ -665,14 +700,14 @@ export function CoursesPage() {
<div> <div>
<label <label
htmlFor="edit-course-description" htmlFor="edit-subcat-description"
className="mb-2 block text-sm font-medium text-grayScale-600" className="mb-2 block text-sm font-medium text-grayScale-600"
> >
Description <span className="text-red-400">*</span> Description
</label> </label>
<textarea <textarea
id="edit-course-description" id="edit-subcat-description"
placeholder="Enter sub-category description" placeholder="Optional description"
value={editDescription} value={editDescription}
onChange={(e) => setEditDescription(e.target.value)} onChange={(e) => setEditDescription(e.target.value)}
rows={4} rows={4}
@ -681,29 +716,18 @@ export function CoursesPage() {
</div> </div>
<div> <div>
<label <label htmlFor="edit-subcat-order" className="mb-2 block text-sm font-medium text-grayScale-600">
htmlFor="edit-course-thumbnail" Display order
className="mb-2 block text-sm font-medium text-grayScale-600"
>
Thumbnail
</label> </label>
<div className="space-y-2">
<FileUpload
accept="image/*"
onFileSelect={(file) => setEditThumbnailFile(file)}
label="Upload thumbnail"
description="JPEG, PNG, WEBP"
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
<Input <Input
id="edit-course-thumbnail" id="edit-subcat-order"
placeholder="Or paste thumbnail URL (https://...)" type="number"
value={editThumbnail} min={0}
onChange={(e) => setEditThumbnail(e.target.value)} value={editDisplayOrder}
onChange={(e) => setEditDisplayOrder(Number(e.target.value))}
/> />
</div> </div>
</div> </div>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end"> <div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={handleCloseEditModal} disabled={updating} className="w-full sm:w-auto"> <Button variant="outline" onClick={handleCloseEditModal} disabled={updating} className="w-full sm:w-auto">
@ -721,122 +745,8 @@ export function CoursesPage() {
</div> </div>
)} )}
{/* Ratings Modal */} {/* Delete Sub-category Modal */}
{showRatingsModal && ( {showDeleteModal && subCategoryToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-lg animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
<h2 className="text-lg font-bold text-grayScale-700">Sub-category Ratings</h2>
<button
onClick={() => {
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"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="max-h-[70vh] overflow-y-auto px-6 py-6">
{courseRatingsLoading ? (
<div className="flex flex-col items-center justify-center py-16">
<SpinnerIcon className="h-8 w-8" />
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading ratings</p>
</div>
) : courseRatings.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-16">
<div className="rounded-full bg-amber-50 p-4">
<Star className="h-8 w-8 text-amber-400" />
</div>
<p className="mt-4 text-sm font-semibold text-grayScale-700">No ratings yet</p>
<p className="mt-1 text-sm text-grayScale-400">
Ratings will appear here once learners start reviewing this sub-category.
</p>
</div>
) : (
<div className="space-y-4">
{/* Summary bar */}
<div className="flex flex-wrap items-center gap-6 rounded-xl border border-grayScale-200 px-5 py-4">
<div className="flex items-center gap-2">
<Star className="h-5 w-5 text-amber-400" fill="currentColor" />
<span className="text-lg font-bold text-grayScale-800">
{(courseRatings.reduce((sum, r) => sum + r.stars, 0) / courseRatings.length).toFixed(1)}
</span>
<span className="text-sm text-grayScale-400">/ 5</span>
</div>
<div className="h-5 w-px bg-grayScale-200" />
<span className="text-sm text-grayScale-500">
{courseRatings.length} review{courseRatings.length !== 1 ? "s" : ""}
</span>
</div>
{/* Rating cards */}
<div className="space-y-3">
{courseRatings.map((rating) => (
<div
key={rating.id}
className="rounded-xl border border-grayScale-200 bg-white p-5 space-y-3"
>
{/* Header row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-brand-50 text-sm font-bold text-brand-600">
U{rating.user_id}
</div>
<div>
<p className="text-sm font-semibold text-grayScale-700">User #{rating.user_id}</p>
<p className="text-[11px] text-grayScale-400">
{new Date(rating.created_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
{rating.updated_at !== rating.created_at && (
<span className="ml-1.5 text-grayScale-300">· edited</span>
)}
</p>
</div>
</div>
{/* Stars */}
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((s) => (
<Star
key={s}
className={`h-4 w-4 ${
s <= rating.stars
? "text-amber-400"
: "text-grayScale-200"
}`}
fill={s <= rating.stars ? "currentColor" : "none"}
/>
))}
</div>
</div>
{/* Review text */}
{rating.review && (
<div className="flex gap-2">
<MessageSquare className="mt-0.5 h-3.5 w-3.5 shrink-0 text-grayScale-300" />
<p className="text-sm leading-relaxed text-grayScale-600">
{rating.review}
</p>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
{/* Delete Course Modal */}
{showDeleteModal && courseToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl"> <div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
@ -855,7 +765,7 @@ export function CoursesPage() {
</div> </div>
<p className="text-center text-sm leading-relaxed text-grayScale-600"> <p className="text-center text-sm leading-relaxed text-grayScale-600">
Are you sure you want to delete{" "} Are you sure you want to delete{" "}
<span className="font-semibold text-grayScale-700">{courseToDelete.title}</span>? This action cannot <span className="font-semibold text-grayScale-700">{subCategoryToDelete.name}</span>? This action cannot
be undone. be undone.
</p> </p>
</div> </div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -58,7 +58,13 @@ const typeColors: Record<QuestionType, string> = {
} }
export function PracticeQuestionsPage() { 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 location = useLocation()
const [questions, setQuestions] = useState<PracticeQuestion[]>([]) const [questions, setQuestions] = useState<PracticeQuestion[]>([])
@ -102,11 +108,14 @@ export function PracticeQuestionsPage() {
const [saveError, setSaveError] = useState<string | null>(null) const [saveError, setSaveError] = useState<string | null>(null)
const backLink = useMemo(() => { 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/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}`
} }
return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${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[] => { const buildDefaultOptions = (type: QuestionType, sampleAnswerText?: string): DraftOption[] => {
if (type === "TRUE_FALSE") { if (type === "TRUE_FALSE") {

View File

@ -1,5 +1,5 @@
import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react" 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 { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
@ -1926,7 +1926,7 @@ export function SpeakingPage() {
className="gap-1.5" className="gap-1.5"
> >
{uploadingIntroVideo ? ( {uploadingIntroVideo ? (
<Loader2 className="h-4 w-4 animate-spin" /> <SpinnerIcon className="h-4 w-4" alt="" />
) : ( ) : (
<Upload className="h-4 w-4" /> <Upload className="h-4 w-4" />
)} )}

View File

@ -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<CategorySubCategoryListItem | null>(null)
const [courses, setCourses] = useState<SubCategoryCourseListItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<div className="flex flex-col items-center justify-center py-32">
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
<p className="mt-4 text-sm text-grayScale-500">Loading courses</p>
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center py-32">
<div className="mx-4 flex w-full max-w-md items-center gap-3 rounded-2xl border border-red-100 bg-red-50 px-6 py-5 shadow-sm">
<img src={alertSrc} alt="" className="h-10 w-10 shrink-0" />
<p className="text-sm font-medium text-red-600">{error}</p>
</div>
</div>
)
}
const label = subCategory?.name ?? "Sub-category"
return (
<div className="space-y-6">
<div className="rounded-2xl border border-grayScale-100 bg-white px-5 py-4 shadow-sm sm:px-6 sm:py-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3.5">
<Link
to={`/content/category/${categoryId}/courses`}
className="mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-grayScale-50 text-grayScale-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<div>
<p className="text-xs font-medium text-grayScale-400">Sub-category</p>
<h1 className="text-xl font-bold text-grayScale-700 sm:text-2xl">{label}</h1>
<p className="mt-0.5 text-sm text-grayScale-400">
{courses.length} course{courses.length !== 1 ? "s" : ""} open a course to manage sub-modules
</p>
</div>
</div>
</div>
</div>
{courses.length === 0 ? (
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
<p className="text-sm font-medium text-grayScale-600">No courses in this sub-category yet</p>
<p className="mt-1 text-sm text-grayScale-400">Add a course from your authoring flow or API.</p>
</div>
) : (
<div className="space-y-3">
{courses.map((c) => (
<button
key={c.id}
type="button"
onClick={() =>
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",
)}
>
<div className="flex min-w-0 items-center gap-3">
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400">
<BookOpen className="h-5 w-5" />
</div>
<div className="min-w-0">
<p className="font-semibold text-grayScale-800">{c.title}</p>
{c.description?.trim() ? (
<p className="mt-0.5 line-clamp-2 text-sm text-grayScale-500">{c.description}</p>
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-3">
<Badge variant={c.is_active ? "success" : "secondary"} className="text-[11px]">
{c.is_active ? "Active" : "Inactive"}
</Badge>
<ChevronRight className="h-5 w-5 text-grayScale-300" />
</div>
</button>
))}
</div>
)}
</div>
)
}

View File

@ -103,9 +103,10 @@ export function SubModuleContentPage() {
try { try {
const subCoursesRes = await getSubModulesByCourse(Number(courseId)) const subCoursesRes = await getSubModulesByCourse(Number(courseId))
const foundSubCourse = subCoursesRes.data.data.sub_courses?.find( const list = subCoursesRes.data?.data?.sub_courses
(sc) => sc.id === Number(subModuleId) const foundSubCourse = Array.isArray(list)
) ? list.find((sc) => sc.id === Number(subModuleId))
: undefined
setSubCourse(foundSubCourse ?? null) setSubCourse(foundSubCourse ?? null)
} catch (err) { } catch (err) {
console.error("Failed to fetch course data:", err) console.error("Failed to fetch course data:", err)
@ -123,7 +124,9 @@ export function SubModuleContentPage() {
setPracticesLoading(true) setPracticesLoading(true)
try { try {
const res = await getQuestionSetsByOwner("SUB_MODULE", Number(subModuleId)) 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) { } catch (err) {
console.error("Failed to fetch practices:", err) console.error("Failed to fetch practices:", err)
} finally { } finally {
@ -136,7 +139,8 @@ export function SubModuleContentPage() {
setVideosLoading(true) setVideosLoading(true)
try { try {
const res = await getVideosBySubModule(Number(subModuleId)) const res = await getVideosBySubModule(Number(subModuleId))
setVideos(res.data.data.videos ?? []) const vids = res.data?.data?.videos ?? []
setVideos(Array.isArray(vids) ? vids : [])
} catch (err) { } catch (err) {
console.error("Failed to fetch videos:", err) console.error("Failed to fetch videos:", err)
} finally { } finally {
@ -154,7 +158,7 @@ export function SubModuleContentPage() {
limit: ratingsPageSize, limit: ratingsPageSize,
offset, offset,
}) })
setRatings(res.data.data ?? []) setRatings(res.data?.data ?? [])
} catch (err) { } catch (err) {
console.error("Failed to fetch ratings:", err) console.error("Failed to fetch ratings:", err)
} finally { } finally {
@ -405,8 +409,8 @@ export function SubModuleContentPage() {
const idMatch = video.video_url?.match(/(\d{5,})/) const idMatch = video.video_url?.match(/(\d{5,})/)
const vimeoId = idMatch?.[1] ?? "76979871" // fallback to Big Buck Bunny const vimeoId = idMatch?.[1] ?? "76979871" // fallback to Big Buck Bunny
const res = await getVimeoSample(vimeoId) const res = await getVimeoSample(vimeoId)
setPreviewIframe(res.data.data.iframe) setPreviewIframe(res.data?.data?.iframe ?? "")
setPreviewVideo(res.data.data.video) setPreviewVideo(res.data?.data?.video ?? null)
} catch { } catch {
setPreviewIframe("") setPreviewIframe("")
} finally { } 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 === "all") return true
if (statusFilter === "published") return practice.status === "PUBLISHED" if (statusFilter === "published") return practice.status === "PUBLISHED"
if (statusFilter === "draft") return practice.status === "DRAFT" if (statusFilter === "draft") return practice.status === "DRAFT"
@ -440,6 +444,19 @@ export function SubModuleContentPage() {
) )
} }
if (!subCourse) {
return (
<div className="flex flex-col items-center justify-center py-20">
<img src={alertSrc} alt="" className="h-12 w-12" />
<p className="mt-3 text-sm font-medium text-grayScale-600">Sub-module not found</p>
<p className="mt-1 text-sm text-grayScale-400">It may have been removed or the link is invalid.</p>
<Button className="mt-6" variant="outline" asChild>
<Link to={`/content/category/${categoryId}/courses/${courseId}/sub-modules`}>Back to sub-modules</Link>
</Button>
</div>
)
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Back Button */} {/* Back Button */}
@ -590,7 +607,7 @@ export function SubModuleContentPage() {
<div className="flex items-center gap-3 text-xs text-grayScale-400"> <div className="flex items-center gap-3 text-xs text-grayScale-400">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5" /> <Layers className="h-3.5 w-3.5" />
<span>{practice.owner_type.replace("_", " ")}</span> <span>{String(practice.owner_type ?? "SUB_MODULE").replace(/_/g, " ")}</span>
</div> </div>
{practice.shuffle_questions && ( {practice.shuffle_questions && (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-600 ring-1 ring-inset ring-amber-200">Shuffle ON</span> <span className="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-600 ring-1 ring-inset ring-amber-200">Shuffle ON</span>
@ -599,11 +616,13 @@ export function SubModuleContentPage() {
<div className="mt-auto flex items-center justify-between border-t border-grayScale-100 pt-3"> <div className="mt-auto flex items-center justify-between border-t border-grayScale-100 pt-3">
<span className="text-xs font-medium text-grayScale-400"> <span className="text-xs font-medium text-grayScale-400">
{new Date(practice.created_at).toLocaleDateString("en-US", { {practice.created_at
? new Date(practice.created_at).toLocaleDateString("en-US", {
month: "short", month: "short",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
})} })
: "—"}
</span> </span>
<div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}> <div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
<button <button

File diff suppressed because it is too large Load Diff

View File

@ -95,12 +95,13 @@ function getStatusConfig(status: string): {
} }
} }
function getIssueTypeConfig(type: string): { function getIssueTypeConfig(type: string | null | undefined): {
label: string; label: string;
classes: string; classes: string;
icon: typeof Bug; icon: typeof Bug;
} { } {
switch (type) { const t = String(type ?? "").trim();
switch (t) {
case "bug": case "bug":
return { return {
label: "Bug", label: "Bug",
@ -133,7 +134,7 @@ function getIssueTypeConfig(type: string): {
}; };
default: default:
return { 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", classes: "bg-grayScale-100 text-grayScale-600 border-grayScale-200",
icon: HelpCircle, icon: HelpCircle,
}; };
@ -173,8 +174,10 @@ function getRelativeTime(dateStr: string): string {
return formatDate(dateStr); return formatDate(dateStr);
} }
function formatRoleLabel(role: string): string { function formatRoleLabel(role: string | null | undefined): string {
return role const r = String(role ?? "").trim();
if (!r) return "—";
return r
.split("_") .split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(" "); .join(" ");
@ -221,8 +224,9 @@ export function IssuesPage() {
offset: (page - 1) * pageSize, offset: (page - 1) * pageSize,
}; };
const res = await getIssues(filters); const res = await getIssues(filters);
setIssues(res.data.data.issues); const payload = res.data?.data;
setTotalCount(res.data.data.total_count); setIssues(Array.isArray(payload?.issues) ? payload.issues : []);
setTotalCount(typeof payload?.total_count === "number" ? payload.total_count : 0);
} catch (error) { } catch (error) {
console.error("Failed to fetch issues:", error); console.error("Failed to fetch issues:", error);
setIssues([]); setIssues([]);
@ -241,7 +245,7 @@ export function IssuesPage() {
setDetailLoading(true); setDetailLoading(true);
try { try {
const res = await getIssueById(issueId); const res = await getIssueById(issueId);
setSelectedIssue(res.data.data); setSelectedIssue(res.data?.data ?? null);
} catch (error) { } catch (error) {
console.error("Failed to fetch issue detail:", error); console.error("Failed to fetch issue detail:", error);
} finally { } finally {
@ -305,16 +309,15 @@ export function IssuesPage() {
}; };
// Client-side filtering (status, type, search) // 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 (statusFilter && issue.status !== statusFilter) return false;
if (typeFilter && issue.issue_type !== typeFilter) return false; if (typeFilter && issue.issue_type !== typeFilter) return false;
if (searchQuery) { if (searchQuery) {
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
return ( const subject = String(issue.subject ?? "").toLowerCase();
issue.subject.toLowerCase().includes(q) || const description = String(issue.description ?? "").toLowerCase();
issue.description.toLowerCase().includes(q) || const issueType = String(issue.issue_type ?? "").toLowerCase();
issue.issue_type.toLowerCase().includes(q) return subject.includes(q) || description.includes(q) || issueType.includes(q);
);
} }
return true; return true;
}); });
@ -537,10 +540,10 @@ export function IssuesPage() {
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-grayScale-600 truncate"> <p className="text-sm font-medium text-grayScale-600 truncate">
{issue.subject} {issue.subject?.trim() ? issue.subject : "—"}
</p> </p>
<p className="text-xs text-grayScale-400 truncate mt-0.5"> <p className="text-xs text-grayScale-400 truncate mt-0.5">
{issue.description} {issue.description?.trim() ? issue.description : "No description"}
</p> </p>
</div> </div>
</div> </div>
@ -572,6 +575,9 @@ export function IssuesPage() {
{getStatusConfig(s).label} {getStatusConfig(s).label}
</option> </option>
))} ))}
{!STATUSES.includes(issue.status as (typeof STATUSES)[number]) && issue.status ? (
<option value={issue.status}>{getStatusConfig(issue.status).label}</option>
) : null}
</select> </select>
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 pointer-events-none opacity-50" /> <ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 pointer-events-none opacity-50" />
</div> </div>

View File

@ -1,11 +1,12 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom" 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 { Card, CardContent } from "../../components/ui/card"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea" import { Textarea } from "../../components/ui/textarea"
import { FileUpload } from "../../components/ui/file-upload" import { FileUpload } from "../../components/ui/file-upload"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { getTeamMembers } from "../../api/team.api" import { getTeamMembers } from "../../api/team.api"
import type { TeamMember } from "../../types/team.types" import type { TeamMember } from "../../types/team.types"
@ -282,7 +283,7 @@ export function CreateNotificationPage() {
> >
{sending ? ( {sending ? (
<> <>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> <SpinnerIcon className="mr-2 h-3.5 w-3.5" alt="" />
Sending Sending
</> </>
) : ( ) : (
@ -347,7 +348,7 @@ export function CreateNotificationPage() {
<div className="max-h-64 space-y-1.5 overflow-y-auto rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-2"> <div className="max-h-64 space-y-1.5 overflow-y-auto rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-2">
{recipientsLoading && ( {recipientsLoading && (
<div className="flex items-center justify-center py-6 text-xs text-grayScale-400"> <div className="flex items-center justify-center py-6 text-xs text-grayScale-400">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <SpinnerIcon className="mr-2 h-4 w-4" alt="" />
Loading users Loading users
</div> </div>
)} )}

View File

@ -1,8 +1,18 @@
import { useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { import {
Plus, Search, Shield, ShieldCheck, ChevronLeft, ChevronRight, Plus,
AlertCircle, Eye, X, Pencil, Check, Search,
Shield,
ShieldCheck,
ChevronLeft,
ChevronRight,
AlertCircle,
Eye,
X,
Pencil,
Check,
Trash2,
} from "lucide-react" } from "lucide-react"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Card, CardContent } from "../../components/ui/card" import { Card, CardContent } from "../../components/ui/card"
@ -12,7 +22,14 @@ import { Textarea } from "../../components/ui/textarea"
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from "../../components/ui/dialog" } 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 type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { toast } from "sonner" import { toast } from "sonner"
@ -36,6 +53,11 @@ export function RolesListPage() {
const [detailOpen, setDetailOpen] = useState(false) const [detailOpen, setDetailOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false) const [detailLoading, setDetailLoading] = useState(false)
// Delete modal state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [roleToDelete, setRoleToDelete] = useState<Role | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false)
// Role info editing state // Role info editing state
const [editingRole, setEditingRole] = useState(false) const [editingRole, setEditingRole] = useState(false)
const [editName, setEditName] = useState("") const [editName, setEditName] = useState("")
@ -59,9 +81,7 @@ export function RolesListPage() {
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [query]) }, [query])
// Fetch roles const fetchRoles = useCallback(async () => {
useEffect(() => {
const fetchRoles = async () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
@ -77,10 +97,13 @@ export function RolesListPage() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}
fetchRoles()
}, [debouncedQuery, page, pageSize]) }, [debouncedQuery, page, pageSize])
// Fetch roles
useEffect(() => {
fetchRoles()
}, [fetchRoles])
// Open role detail // Open role detail
const handleViewRole = async (roleId: number) => { const handleViewRole = async (roleId: number) => {
setDetailOpen(true) setDetailOpen(true)
@ -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 // Enter role info edit mode
const handleEditRole = () => { const handleEditRole = () => {
if (!selectedRole) return if (!selectedRole) return
@ -302,7 +364,7 @@ export function RolesListPage() {
{roles.map((role) => ( {roles.map((role) => (
<Card <Card
key={role.id} key={role.id}
className="overflow-hidden shadow-sm transition-shadow hover:shadow-md" className="overflow-hidden border border-grayScale-100 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md"
> >
<div <div
className={cn( className={cn(
@ -312,7 +374,7 @@ export function RolesListPage() {
: "bg-gradient-to-r from-brand-500 to-brand-600", : "bg-gradient-to-r from-brand-500 to-brand-600",
)} )}
/> />
<CardContent className="p-5"> <CardContent className="space-y-4 p-5">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div <div
@ -330,23 +392,53 @@ export function RolesListPage() {
)} )}
</div> </div>
<div> <div>
<h3 className="text-sm font-semibold text-grayScale-700">{role.name}</h3> <h3 className="text-sm font-semibold uppercase tracking-wide text-grayScale-700">
<p className="mt-0.5 text-xs text-grayScale-400 line-clamp-1"> {role.name}
{role.description} </h3>
<p className="mt-0.5 text-xs text-grayScale-500 line-clamp-2">
{role.description?.trim() || "No description provided for this role."}
</p> </p>
</div> </div>
</div> </div>
{role.is_system && ( <Badge
<Badge variant="warning" className="shrink-0 text-[10px]"> variant={role.is_system ? "warning" : "outline"}
System className="shrink-0 text-[10px]"
>
{role.is_system ? "System" : "Custom"}
</Badge> </Badge>
)}
</div> </div>
<div className="mt-4 flex items-center justify-between"> <div className="grid grid-cols-2 gap-2 rounded-xl border border-grayScale-100 bg-grayScale-50/70 p-2.5 text-[11px]">
<div>
<p className="text-grayScale-400">Role ID</p>
<p className="font-semibold text-grayScale-700">#{role.id}</p>
</div>
<div>
<p className="text-grayScale-400">Created</p>
<p className="font-semibold text-grayScale-700">
{new Date(role.created_at).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-[11px] text-grayScale-400"> <span className="text-[11px] text-grayScale-400">
Created {new Date(role.created_at).toLocaleDateString()} Open details to view permissions
</span> </span>
<div className="flex items-center gap-2">
{!role.is_system && (
<Button
type="button"
variant="destructive"
size="icon"
className="h-8 w-8"
onClick={() => handleDeleteRoleClick(role)}
disabled={deleteLoading}
aria-label={`Delete role ${role.name}`}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -357,6 +449,7 @@ export function RolesListPage() {
View View
</Button> </Button>
</div> </div>
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
@ -689,6 +782,55 @@ export function RolesListPage() {
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Delete role dialog */}
<Dialog
open={deleteDialogOpen}
onOpenChange={(open) => {
setDeleteDialogOpen(open)
if (!open) handleCancelDeleteRole()
}}
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<Trash2 className="h-5 w-5" />
Delete Role
</DialogTitle>
<DialogDescription>
Are you sure you want to delete this role? This action cannot be undone.
</DialogDescription>
</DialogHeader>
{roleToDelete && (
<div className="rounded-lg bg-red-50 border border-red-100 p-3">
<p className="text-sm font-medium text-red-700">{roleToDelete.name}</p>
<p className="text-xs text-red-500 mt-0.5">Role #{roleToDelete.id}</p>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={handleCancelDeleteRole}
disabled={deleteLoading}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
className="gap-1.5"
disabled={deleteLoading || !roleToDelete}
onClick={handleConfirmDeleteRole}
>
{deleteLoading ? <SpinnerIcon className="h-3.5 w-3.5" /> : <Trash2 className="h-3.5 w-3.5" />}
{deleteLoading ? "Deleting..." : "Delete"}
</Button>
</div>
</DialogContent>
</Dialog>
</div> </div>
) )
} }

View File

@ -45,6 +45,7 @@ export interface GetCoursesResponse {
export interface CreateCourseRequest { export interface CreateCourseRequest {
category_id: number category_id: number
sub_category_id?: number | null
title: string title: string
description: string description: string
} }
@ -172,7 +173,13 @@ export interface GetModulesResponse {
export interface CreateModuleRequest { export interface CreateModuleRequest {
level_id: number level_id: number
title: string 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 */ /** @deprecated Use UpdateSubCourseRequest instead */
@ -192,6 +199,8 @@ export interface UpdateModuleStatusRequest {
export interface SubCourse { export interface SubCourse {
id: number id: number
course_id: number course_id: number
/** Present when derived from course hierarchy rows (levels → modules → sub-modules). */
level_id?: number
module_id?: number module_id?: number
title: string title: string
description: string description: string
@ -701,6 +710,72 @@ export interface HumanLanguageLesson {
practices: LearningPathPractice[] 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 { export interface GetHumanLanguageLessonsResponse {
message: string message: string
data: { data: {
@ -714,10 +789,209 @@ export interface GetHumanLanguageLessonsResponse {
metadata: unknown 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 { export interface HumanLanguageSubModule {
id: number id: number
title: string title: string
videos: LearningPathVideo[] 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[] practices: LearningPathPractice[]
} }
@ -728,6 +1002,7 @@ export interface HumanLanguageModule {
} }
export interface HumanLanguageLevelTree { export interface HumanLanguageLevelTree {
level_id?: number
level: string level: string
modules: HumanLanguageModule[] modules: HumanLanguageModule[]
} }

View File

@ -60,6 +60,14 @@ export interface CreateRoleResponse {
metadata: unknown 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 { export interface SetRolePermissionsRequest {
permission_ids: number[] permission_ids: number[]
} }