Merge branch 'el-ui' into main (prefer el-ui on conflicts)
This commit is contained in:
commit
dc07ab72d2
479
docs/course-management-api-integration.md
Normal file
479
docs/course-management-api-integration.md
Normal 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.
|
||||||
|
|
||||||
|
|
@ -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`,
|
||||||
|
|
|
||||||
108
src/api/http.ts
108
src/api/http.ts
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}`)
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
650
src/pages/content-management/AddNewLessonPage.tsx
Normal file
650
src/pages/content-management/AddNewLessonPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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…
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
1310
src/pages/content-management/HumanLanguageHierarchyPage.tsx
Normal file
1310
src/pages/content-management/HumanLanguageHierarchyPage.tsx
Normal file
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
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
144
src/pages/content-management/SubCategoryCoursesPage.tsx
Normal file
144
src/pages/content-management/SubCategoryCoursesPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user