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
category_id: r.category_id, ? rows.filter((r) => r.category_name?.trim().toLowerCase() === requestedCategoryName)
sub_category_id: r.sub_category_id ?? null, : requestedCategoryRows
title: r.course_title ?? "",
description: "", const courseMap = new Map<number, { id: number; category_id: number; sub_category_id: number | null; title: string; description: string; thumbnail: string; is_active: boolean }>()
thumbnail: "", relevantRows
is_active: true, .filter((r) => r.course_id)
})) .forEach((r) => {
const id = Number(r.course_id)
if (!Number.isFinite(id)) return
if (courseMap.has(id)) return
courseMap.set(id, {
id,
category_id: r.category_id,
sub_category_id: r.sub_category_id ?? null,
title: r.course_title ?? "",
description: "",
thumbnail: "",
is_active: true,
})
})
const courses = Array.from(courseMap.values())
return { 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()
return { const bundles = await Promise.all(
...res, modulePairs.map(async ({ level, module }) => {
data: { const subsRes = await getSubModulesByModuleId(module.id)
...res.data, const rawSubs = subsRes.data?.data?.sub_modules
data: { const subs = Array.isArray(rawSubs) ? rawSubs : []
category_id: selectedCategory.category_id, const sortedSubs = [...subs].sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
category_name: selectedCategory.category_name, return { level, module, subs: sortedSubs }
sub_categories: subCategories, }),
}, )
},
} as unknown as { data: GetHumanLanguageHierarchyResponse } for (const { level, module, subs } of bundles) {
}) const found = subs.find((s) => s.id === subModuleId)
if (found) {
return {
id: found.id,
course_id: courseId,
level_id: level.id,
module_id: module.id,
title: found.title,
description: found.description ?? "",
level: level.cefr_level,
cefr_level: level.cefr_level,
thumbnail: found.thumbnail ?? "",
display_order: found.display_order,
sub_level: level.cefr_level,
is_active: found.is_active,
}
}
}
} catch (e) {
console.error("resolveSubModuleForCourse failed:", e)
}
return null
}
export const getCourseLevelsForCourse = (courseId: number) =>
http.get<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>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600"> <div className="flex items-center justify-between gap-2">
View Sub-categories <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-block transition-transform duration-300 group-hover:translate-x-1"> View Sub-categories
<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
}), }),
) ),
const practiceMap: Record<number, PracticeListItem[]> = {} Promise.all(
ownerResults.forEach(([subCourseId, practiceItems]) => { subCourses.map(async (sc) => {
practiceMap[subCourseId] = practiceItems const videosRes = await getVideosBySubModule(sc.id)
}) const rows = videosRes.data?.data?.videos ?? []
setPracticesBySubCourse(practiceMap) const mapped = sortByDisplayOrder(
} else { rows.map((video: any, idx: number) => ({
setPracticesBySubCourse({}) 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[]> = {}
ownerResults.forEach(([subCourseId, practiceItems]) => {
practiceMap[subCourseId] = practiceItems
})
setPracticesBySubCourse(practiceMap)
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>

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

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
month: "short", ? new Date(practice.created_at).toLocaleDateString("en-US", {
day: "numeric", month: "short",
year: "numeric", day: "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,27 +81,28 @@ export function RolesListPage() {
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [query]) }, [query])
const fetchRoles = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await getRoles({
query: debouncedQuery || undefined,
page,
page_size: pageSize,
})
setRoles(res.data.data.roles ?? [])
setTotal(res.data.data.total ?? 0)
} catch {
setError("Failed to load roles.")
} finally {
setLoading(false)
}
}, [debouncedQuery, page, pageSize])
// Fetch roles // Fetch roles
useEffect(() => { useEffect(() => {
const fetchRoles = async () => {
setLoading(true)
setError(null)
try {
const res = await getRoles({
query: debouncedQuery || undefined,
page,
page_size: pageSize,
})
setRoles(res.data.data.roles ?? [])
setTotal(res.data.data.total ?? 0)
} catch {
setError("Failed to load roles.")
} finally {
setLoading(false)
}
}
fetchRoles() fetchRoles()
}, [debouncedQuery, page, pageSize]) }, [fetchRoles])
// Open role detail // Open role detail
const handleViewRole = async (roleId: number) => { const handleViewRole = async (roleId: number) => {
@ -97,6 +120,45 @@ export function RolesListPage() {
} }
} }
const handleDeleteRoleClick = (role: Role) => {
setRoleToDelete(role)
setDeleteDialogOpen(true)
}
const handleCancelDeleteRole = () => {
setDeleteDialogOpen(false)
setRoleToDelete(null)
}
const handleConfirmDeleteRole = async () => {
if (!roleToDelete) return
setDeleteLoading(true)
try {
const res = await deleteRole(roleToDelete.id)
toast.success(res.data.message ?? "Role deleted successfully")
// Close dialogs if the deleted role is currently opened.
if (selectedRole?.id === roleToDelete.id) {
setDetailOpen(false)
setSelectedRole(null)
setEditingPermissions(false)
setEditingRole(false)
setPermSearch("")
}
setRoleToDelete(null)
setDeleteDialogOpen(false)
await fetchRoles()
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
"Failed to delete role."
toast.error(message)
} finally {
setDeleteLoading(false)
}
}
// Enter role info edit mode // 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,32 +392,63 @@ 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]"
</Badge> >
)} {role.is_system ? "System" : "Custom"}
</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>
<Button <div className="flex items-center gap-2">
variant="outline" {!role.is_system && (
size="sm" <Button
className="h-8 gap-1.5 text-xs" type="button"
onClick={() => handleViewRole(role.id)} variant="destructive"
> size="icon"
<Eye className="h-3.5 w-3.5" /> className="h-8 w-8"
View onClick={() => handleDeleteRoleClick(role)}
</Button> disabled={deleteLoading}
aria-label={`Delete role ${role.name}`}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={() => handleViewRole(role.id)}
>
<Eye className="h-3.5 w-3.5" />
View
</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[]
} }