ui
This commit is contained in:
commit
6181334db7
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.
|
||||
|
||||
|
|
@ -44,18 +44,49 @@ import type {
|
|||
GetQuestionsResponse,
|
||||
CreateVimeoVideoRequest,
|
||||
CreateCourseCategoryRequest,
|
||||
GetCategorySubCategoriesResponse,
|
||||
GetSubCategoryCoursesResponse,
|
||||
GetSubCoursePrerequisitesResponse,
|
||||
AddSubCoursePrerequisiteRequest,
|
||||
GetLearningPathResponse,
|
||||
GetHumanLanguageLessonsResponse,
|
||||
GetHumanLanguageHierarchyResponse,
|
||||
GetCourseHierarchyResponse,
|
||||
CreateHumanLanguageLessonRequest,
|
||||
GetSubModuleLessonsResponse,
|
||||
GetSubModuleLessonDetailResponse,
|
||||
UpdateSubModuleLessonRequest,
|
||||
UpdateSubModuleLessonResponse,
|
||||
GetCourseLevelsForCourseResponse,
|
||||
GetSubModulesByModuleResponse,
|
||||
SubCourse,
|
||||
GetSubCourseEntryAssessmentResponse,
|
||||
ReorderItem,
|
||||
GetRatingsResponse,
|
||||
GetRatingsParams,
|
||||
GetVimeoSampleResponse,
|
||||
CreateCourseVideoRequest,
|
||||
GetLearningProgramsResponse,
|
||||
UpdateLearningProgramRequest,
|
||||
CreateLearningProgramRequest,
|
||||
CreateLearningProgramResponse,
|
||||
GetProgramCoursesResponse,
|
||||
GetTopLevelCourseModulesResponse,
|
||||
UpdateTopLevelCourseRequest,
|
||||
UpdateTopLevelCourseModuleRequest,
|
||||
CreateTopLevelCourseModuleRequest,
|
||||
CreateTopLevelCourseModuleResponse,
|
||||
CreateProgramCourseRequest,
|
||||
CreateProgramCourseResponse,
|
||||
GetTopLevelModuleLessonsResponse,
|
||||
GetPracticesByParentContextResponse,
|
||||
CreateParentLinkedPracticeRequest,
|
||||
CreateParentLinkedPracticeResponse,
|
||||
UpdateParentLinkedPracticeRequest,
|
||||
UpdateParentLinkedPracticeResponse,
|
||||
UpdateTopLevelModuleLessonRequest,
|
||||
CreateTopLevelModuleLessonRequest,
|
||||
CreateTopLevelModuleLessonResponse,
|
||||
} from "../types/course.types"
|
||||
|
||||
type UnifiedHierarchyRow = {
|
||||
|
|
@ -110,6 +141,35 @@ export const createCourseCategory = (data: CreateCourseCategoryRequest) =>
|
|||
? http.post("/course-management/sub-categories", { category_id: data.parent_id, name: data.name })
|
||||
: http.post("/course-management/categories", { name: data.name })
|
||||
|
||||
export const deleteCourseCategory = (categoryId: number) =>
|
||||
http.delete(`/course-management/categories/${categoryId}`)
|
||||
|
||||
export const getSubCategoriesByCategoryId = (categoryId: number) =>
|
||||
http.get<GetCategorySubCategoriesResponse>(`/course-management/categories/${categoryId}/sub-categories`)
|
||||
|
||||
export const getCoursesBySubCategoryId = (subCategoryId: number) =>
|
||||
http.get<GetSubCategoryCoursesResponse>(`/course-management/sub-categories/${subCategoryId}/courses`)
|
||||
|
||||
export const createSubCategory = (payload: {
|
||||
category_id: number
|
||||
name: string
|
||||
description?: string | null
|
||||
display_order?: number
|
||||
}) => http.post("/course-management/sub-categories", payload)
|
||||
|
||||
export const deleteCourseSubCategory = (subCategoryId: number) =>
|
||||
http.delete(`/course-management/sub-categories/${subCategoryId}`)
|
||||
|
||||
export const updateSubCategory = (
|
||||
subCategoryId: number,
|
||||
payload: Partial<{
|
||||
name: string
|
||||
description: string | null
|
||||
is_active: boolean
|
||||
display_order: number
|
||||
}>,
|
||||
) => http.patch(`/course-management/sub-categories/${subCategoryId}`, payload)
|
||||
|
||||
export const getCoursesByCategory = (categoryId: number) =>
|
||||
http.get("/course-management/hierarchy").then((res) => {
|
||||
const rows: UnifiedHierarchyRow[] = res.data?.data ?? []
|
||||
|
|
@ -148,9 +208,13 @@ export const updateCourse = (courseId: number, data: UpdateCourseRequest) =>
|
|||
http.put(`/course-management/courses/${courseId}`, data)
|
||||
|
||||
// Sub-Module APIs (Unified Hierarchy)
|
||||
export const getCourseHierarchyByCourseId = (courseId: number) =>
|
||||
http.get<GetCourseHierarchyResponse>(`/course-management/courses/${courseId}/hierarchy`)
|
||||
|
||||
export const getSubModulesByCourse = (courseId: number) =>
|
||||
http.get(`/course-management/courses/${courseId}/hierarchy`).then((res) => {
|
||||
const rows: CourseHierarchyRow[] = res.data?.data ?? []
|
||||
const raw = res.data?.data
|
||||
const rows: CourseHierarchyRow[] = Array.isArray(raw) ? raw : []
|
||||
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 }>()
|
||||
rows.forEach((r, idx) => {
|
||||
if (!r.sub_module_id) return
|
||||
|
|
@ -225,6 +289,27 @@ export const deleteSubModule = (subModuleId: number) =>
|
|||
export const getVideosBySubModule = (subModuleId: number) =>
|
||||
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?: { 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) =>
|
||||
http.post("/course-management/sub-module-videos", {
|
||||
sub_module_id: data.sub_module_id ?? data.sub_course_id,
|
||||
|
|
@ -345,6 +430,126 @@ export const updatePracticeQuestion = (questionId: number, data: UpdatePracticeQ
|
|||
export const deletePracticeQuestion = (questionId: number) =>
|
||||
http.delete(`/questions/${questionId}`)
|
||||
|
||||
/** Top-level learning programs (Learn English cards, etc.) — GET /programs */
|
||||
export const getLearningPrograms = (params?: { limit?: number; offset?: number }) =>
|
||||
http.get<GetLearningProgramsResponse>("/programs", { params })
|
||||
|
||||
export const createLearningProgram = (data: CreateLearningProgramRequest) =>
|
||||
http.post<CreateLearningProgramResponse>("/programs", data)
|
||||
|
||||
export const getProgramCourses = (
|
||||
programId: number,
|
||||
params?: { limit?: number; offset?: number },
|
||||
) => http.get<GetProgramCoursesResponse>(`/programs/${programId}/courses`, { params })
|
||||
|
||||
export const createProgramCourse = (
|
||||
programId: number,
|
||||
data: CreateProgramCourseRequest,
|
||||
) => http.post<CreateProgramCourseResponse>(`/programs/${programId}/courses`, data)
|
||||
|
||||
/** Top-level course resource (Learn English track) — PUT /courses/:id */
|
||||
export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) =>
|
||||
http.put(`/courses/${courseId}`, data)
|
||||
|
||||
export const deleteTopLevelCourse = (courseId: number) =>
|
||||
http.delete(`/courses/${courseId}`)
|
||||
|
||||
export const getTopLevelCourseModules = (
|
||||
courseId: number,
|
||||
params?: { limit?: number; offset?: number },
|
||||
) =>
|
||||
http.get<GetTopLevelCourseModulesResponse>(`/courses/${courseId}/modules`, {
|
||||
params,
|
||||
})
|
||||
|
||||
/** Learn English top-level module — POST /courses/:courseId/modules */
|
||||
export const createTopLevelCourseModule = (
|
||||
courseId: number,
|
||||
data: CreateTopLevelCourseModuleRequest,
|
||||
) =>
|
||||
http.post<CreateTopLevelCourseModuleResponse>(
|
||||
`/courses/${courseId}/modules`,
|
||||
data,
|
||||
)
|
||||
|
||||
/** Learn English top-level module — PUT /modules/:id */
|
||||
export const updateTopLevelCourseModule = (
|
||||
moduleId: number,
|
||||
data: UpdateTopLevelCourseModuleRequest,
|
||||
) => http.put(`/modules/${moduleId}`, data)
|
||||
|
||||
/** Learn English top-level module — DELETE /modules/:id */
|
||||
export const deleteTopLevelCourseModule = (moduleId: number) =>
|
||||
http.delete(`/modules/${moduleId}`)
|
||||
|
||||
/** Learn English top-level module lessons — GET /modules/:moduleId/lessons */
|
||||
export const getModuleLessons = (
|
||||
moduleId: number,
|
||||
params?: { limit?: number; offset?: number },
|
||||
) =>
|
||||
http.get<GetTopLevelModuleLessonsResponse>(`/modules/${moduleId}/lessons`, {
|
||||
params,
|
||||
})
|
||||
|
||||
/** Learn English top-level module lesson — POST /modules/:moduleId/lessons */
|
||||
export const createModuleLesson = (
|
||||
moduleId: number,
|
||||
data: CreateTopLevelModuleLessonRequest,
|
||||
) =>
|
||||
http.post<CreateTopLevelModuleLessonResponse>(`/modules/${moduleId}/lessons`, data)
|
||||
|
||||
/** Learn English top-level module lesson — PUT /lessons/:id */
|
||||
export const updateTopLevelModuleLesson = (
|
||||
lessonId: number,
|
||||
data: UpdateTopLevelModuleLessonRequest,
|
||||
) => http.put(`/lessons/${lessonId}`, data)
|
||||
|
||||
/** Learn English top-level module lesson — DELETE /lessons/:id */
|
||||
export const deleteTopLevelModuleLesson = (lessonId: number) =>
|
||||
http.delete(`/lessons/${lessonId}`)
|
||||
|
||||
/** GET /courses/:courseId/practices — practices linked to a top-level course (at most one in normal use). */
|
||||
export const getPracticesByParentCourse = (
|
||||
courseId: number,
|
||||
params?: { limit?: number; offset?: number },
|
||||
) =>
|
||||
http.get<GetPracticesByParentContextResponse>(`/courses/${courseId}/practices`, { params })
|
||||
|
||||
/** GET /modules/:moduleId/practices */
|
||||
export const getPracticesByParentModule = (
|
||||
moduleId: number,
|
||||
params?: { limit?: number; offset?: number },
|
||||
) =>
|
||||
http.get<GetPracticesByParentContextResponse>(`/modules/${moduleId}/practices`, { params })
|
||||
|
||||
/** GET /lessons/:lessonId/practices */
|
||||
export const getPracticesByParentLesson = (
|
||||
lessonId: number,
|
||||
params?: { limit?: number; offset?: number },
|
||||
) =>
|
||||
http.get<GetPracticesByParentContextResponse>(`/lessons/${lessonId}/practices`, { params })
|
||||
|
||||
/** POST /practices — create a practice (story + question set) for course / module / lesson. */
|
||||
export const createParentLinkedPractice = (data: CreateParentLinkedPracticeRequest) =>
|
||||
http.post<CreateParentLinkedPracticeResponse>("/practices", data)
|
||||
|
||||
/** PUT /practices/:id */
|
||||
export const updateParentLinkedPractice = (
|
||||
practiceId: number,
|
||||
data: UpdateParentLinkedPracticeRequest,
|
||||
) => http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data)
|
||||
|
||||
/** DELETE /practices/:id */
|
||||
export const deleteParentLinkedPractice = (practiceId: number) =>
|
||||
http.delete<{ message: string; success: boolean; status_code: number; metadata: unknown }>(
|
||||
`/practices/${practiceId}`,
|
||||
)
|
||||
|
||||
export const updateLearningProgram = (programId: number, data: UpdateLearningProgramRequest) =>
|
||||
http.put(`/programs/${programId}`, data)
|
||||
|
||||
export const deleteLearningProgram = (programId: number) => http.delete(`/programs/${programId}`)
|
||||
|
||||
// ============================================
|
||||
// Legacy APIs (deprecated - using SubCourse hierarchy now)
|
||||
// Keeping for backward compatibility
|
||||
|
|
@ -383,6 +588,74 @@ export const deleteLevel = (levelId: number) =>
|
|||
export const getModulesByLevel = (levelId: number) =>
|
||||
http.get<GetModulesResponse>(`/course-management/levels/${levelId}/modules`)
|
||||
|
||||
export const getCourseLevelsForCourse = (courseId: number) =>
|
||||
http.get<GetCourseLevelsForCourseResponse>(`/course-management/courses/${courseId}/levels`)
|
||||
|
||||
export const getSubModulesByModuleId = (moduleId: number) =>
|
||||
http.get<GetSubModulesByModuleResponse>(`/course-management/modules/${moduleId}/sub-modules`)
|
||||
|
||||
/**
|
||||
* Finds a sub-module under a course by walking levels → modules → sub-modules APIs.
|
||||
*/
|
||||
export async function resolveSubModuleForCourse(
|
||||
courseId: number,
|
||||
subModuleId: number,
|
||||
): Promise<SubCourse | null> {
|
||||
try {
|
||||
const levelsRes = await getCourseLevelsForCourse(courseId)
|
||||
const levels = Array.isArray(levelsRes.data?.data?.levels) ? levelsRes.data.data.levels : []
|
||||
const sortedLevels = [...levels].sort((a, b) => {
|
||||
const o = (a.display_order ?? 0) - (b.display_order ?? 0)
|
||||
if (o !== 0) return o
|
||||
return String(a.cefr_level ?? "").localeCompare(String(b.cefr_level ?? ""))
|
||||
})
|
||||
|
||||
const modulesNested = await Promise.all(
|
||||
sortedLevels.map(async (level) => {
|
||||
const modsRes = await getModulesByLevel(level.id)
|
||||
const rawMods = modsRes.data?.data?.modules
|
||||
const modules = Array.isArray(rawMods) ? rawMods : []
|
||||
const sortedMods = [...modules].sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
|
||||
return sortedMods.map((module) => ({ level, module }))
|
||||
}),
|
||||
)
|
||||
const modulePairs = modulesNested.flat()
|
||||
|
||||
const bundles = await Promise.all(
|
||||
modulePairs.map(async ({ level, module }) => {
|
||||
const subsRes = await getSubModulesByModuleId(module.id)
|
||||
const rawSubs = subsRes.data?.data?.sub_modules
|
||||
const subs = Array.isArray(rawSubs) ? rawSubs : []
|
||||
const sortedSubs = [...subs].sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
|
||||
return { level, module, subs: sortedSubs }
|
||||
}),
|
||||
)
|
||||
|
||||
for (const { level, module, subs } of bundles) {
|
||||
const found = subs.find((s) => s.id === subModuleId)
|
||||
if (found) {
|
||||
return {
|
||||
id: found.id,
|
||||
course_id: courseId,
|
||||
level_id: level.id,
|
||||
module_id: module.id,
|
||||
title: found.title,
|
||||
description: found.description ?? "",
|
||||
level: level.cefr_level,
|
||||
cefr_level: level.cefr_level,
|
||||
thumbnail: found.thumbnail ?? "",
|
||||
display_order: found.display_order,
|
||||
sub_level: level.cefr_level,
|
||||
is_active: found.is_active,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("resolveSubModuleForCourse failed:", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const createModule = (data: CreateModuleRequest) =>
|
||||
http.post("/course-management/modules", data)
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,16 @@ export interface ResolveFileUrlResponse {
|
|||
success?: boolean
|
||||
}
|
||||
|
||||
export interface RefreshFileUrlResponse {
|
||||
message: string
|
||||
data?: {
|
||||
object_key?: string
|
||||
url?: string
|
||||
expires_in?: number
|
||||
}
|
||||
success?: boolean
|
||||
}
|
||||
|
||||
export interface UploadMediaOptions {
|
||||
title?: string
|
||||
description?: string
|
||||
|
|
@ -86,3 +96,8 @@ export const resolveFileUrl = (key: string) =>
|
|||
params: { key },
|
||||
})
|
||||
|
||||
export const refreshFileUrl = (reference: string) =>
|
||||
http.post<RefreshFileUrlResponse>("/files/refresh-url", {
|
||||
reference,
|
||||
})
|
||||
|
||||
|
|
|
|||
136
src/api/http.ts
136
src/api/http.ts
|
|
@ -12,6 +12,7 @@ let failedQueue: Array<{
|
|||
resolve: (token: string) => void;
|
||||
reject: (error: Error) => void;
|
||||
}> = [];
|
||||
const TOKEN_REFRESH_BUFFER_SECONDS = 120;
|
||||
|
||||
const processQueue = (error: Error | null, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
|
|
@ -32,23 +33,68 @@ const clearAuthAndRedirect = () => {
|
|||
window.location.href = "/login";
|
||||
};
|
||||
|
||||
const refreshAccessToken = async (): Promise<string> => {
|
||||
const accessToken = localStorage.getItem("access_token");
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
const role = localStorage.getItem("role");
|
||||
const memberId = localStorage.getItem("member_id");
|
||||
const decodeJwtPayload = (token: string): Record<string, unknown> | null => {
|
||||
try {
|
||||
const payloadPart = token.split(".")[1];
|
||||
if (!payloadPart) return null;
|
||||
const normalized = payloadPart.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
||||
const json = atob(padded);
|
||||
return JSON.parse(json) as Record<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 ABSOLUTE_URL_REGEX = /^https?:\/\//i;
|
||||
|
||||
const safeOrigin = (url?: string): string | null => {
|
||||
if (!url) return null;
|
||||
try {
|
||||
return new URL(url).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const API_BASE_ORIGIN = safeOrigin(import.meta.env.VITE_API_BASE_URL);
|
||||
|
||||
const shouldAttachApiAuth = (url?: string): boolean => {
|
||||
if (!url) return true;
|
||||
if (!ABSOLUTE_URL_REGEX.test(url)) return true;
|
||||
const requestOrigin = safeOrigin(url);
|
||||
if (!requestOrigin || !API_BASE_ORIGIN) return false;
|
||||
return requestOrigin === API_BASE_ORIGIN;
|
||||
};
|
||||
|
||||
const refreshAccessToken = async (): Promise<string> => {
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error("No refresh token available");
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/auth/refresh`,
|
||||
`${import.meta.env.VITE_API_BASE_URL}/team/refresh`,
|
||||
{
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
role: role || "admin",
|
||||
member_id: Number(memberId),
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -65,9 +111,47 @@ const refreshAccessToken = async (): Promise<string> => {
|
|||
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
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
http.interceptors.request.use(async (config) => {
|
||||
if (!shouldAttachApiAuth(config.url)) {
|
||||
return config;
|
||||
}
|
||||
|
||||
if (isAuthEndpointRequest(config.url)) {
|
||||
return config;
|
||||
}
|
||||
|
||||
let token = localStorage.getItem("access_token");
|
||||
if (token && isAccessTokenExpiringSoon(token)) {
|
||||
token = await getValidAccessToken();
|
||||
}
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
|
@ -80,37 +164,25 @@ http.interceptors.response.use(
|
|||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then((token) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
return http(originalRequest);
|
||||
})
|
||||
.catch((err) => Promise.reject(err));
|
||||
}
|
||||
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
!originalRequest._retry &&
|
||||
shouldAttachApiAuth(originalRequest.url) &&
|
||||
!isAuthEndpointRequest(originalRequest.url)
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const newToken = await refreshAccessToken();
|
||||
processQueue(null, newToken);
|
||||
const newToken = await getValidAccessToken(true);
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
return http(originalRequest);
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError as Error, null);
|
||||
clearAuthAndRedirect();
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Backend is down (network error, timeout, connection refused)
|
||||
if (!error.response) {
|
||||
if (!error.response && shouldAttachApiAuth(originalRequest.url)) {
|
||||
clearAuthAndRedirect();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
GetRolesParams,
|
||||
CreateRoleRequest,
|
||||
CreateRoleResponse,
|
||||
DeleteRoleResponse,
|
||||
SetRolePermissionsRequest,
|
||||
GetPermissionsResponse,
|
||||
} from "../types/rbac.types"
|
||||
|
|
@ -26,3 +27,6 @@ export const setRolePermissions = (roleId: number, data: SetRolePermissionsReque
|
|||
|
||||
export const getAllPermissions = () =>
|
||||
http.get<GetPermissionsResponse>("/rbac/permissions")
|
||||
|
||||
export const deleteRole = (roleId: number) =>
|
||||
http.delete<DeleteRoleResponse>(`/rbac/roles/${roleId}`)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { AppLayout } from "../layouts/AppLayout";
|
|||
import { DashboardPage } from "../pages/DashboardPage";
|
||||
import { AnalyticsPage } from "../pages/analytics/AnalyticsPage";
|
||||
import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout";
|
||||
import { CourseCategoryPage } from "../pages/content-management/CourseCategoryPage";
|
||||
import { AllCoursesPage } from "../pages/content-management/AllCoursesPage";
|
||||
import { CourseFlowBuilderPage } from "../pages/content-management/CourseFlowBuilderPage";
|
||||
import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage";
|
||||
|
|
@ -47,7 +46,7 @@ import { PracticeDetailsPage } from "../pages/content-management/PracticeDetails
|
|||
import { PracticeMembersPage } from "../pages/content-management/PracticeMembersPage";
|
||||
import { QuestionsPage } from "../pages/content-management/QuestionsPage";
|
||||
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage";
|
||||
import { HumanLanguagePage } from "../pages/content-management/HumanLanguagePage";
|
||||
import { HumanLanguageHierarchyPage } from "../pages/content-management/HumanLanguageHierarchyPage";
|
||||
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage";
|
||||
import { UserLogPage } from "../pages/user-log/UserLogPage";
|
||||
import { IssuesPage } from "../pages/issues/IssuesPage";
|
||||
|
|
@ -91,10 +90,10 @@ export function AppRoutes() {
|
|||
</Route>
|
||||
|
||||
<Route path="/content" element={<ContentManagementLayout />}>
|
||||
<Route index element={<CourseCategoryPage />} />
|
||||
<Route index element={<Navigate to="practices" replace />} />
|
||||
<Route path="courses" element={<AllCoursesPage />} />
|
||||
<Route path="flows" element={<CourseFlowBuilderPage />} />
|
||||
<Route path="human-language" element={<HumanLanguagePage />} />
|
||||
<Route path="human-language" element={<HumanLanguageHierarchyPage />} />
|
||||
<Route
|
||||
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/add-practice"
|
||||
element={<AddNewPracticePage />}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export function Topbar({ onSidebarToggle }: TopbarProps) {
|
|||
}
|
||||
|
||||
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 */}
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
import { useState, useCallback } from "react"
|
||||
import { Navigate, Outlet } from "react-router-dom"
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react"
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom"
|
||||
import { Sidebar } from "../components/sidebar/Sidebar"
|
||||
import { Topbar } from "../components/topbar/Topbar"
|
||||
|
||||
export function AppLayout() {
|
||||
const [sidebarOpen, setSidebarOpen] = 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")
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
const handleSidebarToggle = useCallback(() => {
|
||||
setSidebarOpen((prev) => !prev)
|
||||
|
|
@ -20,6 +22,43 @@ export function AppLayout() {
|
|||
setSidebarOpen(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const container = mainRef.current
|
||||
if (!container) return
|
||||
|
||||
const saveScroll = (key: string) => {
|
||||
sessionStorage.setItem(`${scrollStoragePrefix}${key}`, String(container.scrollTop || 0))
|
||||
}
|
||||
|
||||
const previousKey = previousRouteKeyRef.current
|
||||
if (previousKey && previousKey !== routeKey) {
|
||||
saveScroll(previousKey)
|
||||
}
|
||||
previousRouteKeyRef.current = routeKey
|
||||
|
||||
const restoreRaw = sessionStorage.getItem(`${scrollStoragePrefix}${routeKey}`)
|
||||
const restoreTop = restoreRaw ? Number(restoreRaw) : 0
|
||||
const top = Number.isFinite(restoreTop) && restoreTop > 0 ? restoreTop : 0
|
||||
requestAnimationFrame(() => {
|
||||
container.scrollTo({ top, behavior: "auto" })
|
||||
})
|
||||
|
||||
const onScroll = () => saveScroll(routeKey)
|
||||
const onBeforeUnload = () => saveScroll(routeKey)
|
||||
container.addEventListener("scroll", onScroll, { passive: true })
|
||||
window.addEventListener("beforeunload", onBeforeUnload)
|
||||
|
||||
return () => {
|
||||
saveScroll(routeKey)
|
||||
container.removeEventListener("scroll", onScroll)
|
||||
window.removeEventListener("beforeunload", onBeforeUnload)
|
||||
}
|
||||
}, [routeKey])
|
||||
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-grayScale-100">
|
||||
<Sidebar
|
||||
|
|
@ -34,7 +73,7 @@ export function AppLayout() {
|
|||
}`}
|
||||
>
|
||||
<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 />
|
||||
</main>
|
||||
<footer className="border-t bg-grayScale-50 px-4 py-3 lg:px-6">
|
||||
|
|
|
|||
13
src/lib/sessionRole.ts
Normal file
13
src/lib/sessionRole.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
const ADMIN_OR_SUPER: ReadonlySet<string> = new Set([
|
||||
"admin",
|
||||
"super_admin",
|
||||
]);
|
||||
|
||||
/**
|
||||
* True when the stored session role is admin or super_admin (login stores `role` in localStorage).
|
||||
*/
|
||||
export function isAdminOrSuperAdminRole(): boolean {
|
||||
const raw = localStorage.getItem("role");
|
||||
if (!raw) return false;
|
||||
return ADMIN_OR_SUPER.has(raw.trim().toLowerCase());
|
||||
}
|
||||
124
src/lib/videoPreview.ts
Normal file
124
src/lib/videoPreview.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* Resolves a user-facing video URL into something we can preview (iframe or <video>).
|
||||
*/
|
||||
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
|
||||
export function toYoutubeEmbedUrl(rawUrl: string): string | null {
|
||||
try {
|
||||
const u = new URL(rawUrl.trim());
|
||||
const host = u.hostname.replace(/^www\./, "").toLowerCase();
|
||||
if (host === "youtu.be") {
|
||||
const id = u.pathname.split("/").filter(Boolean)[0];
|
||||
if (id) return `https://www.youtube.com/embed/${id}`;
|
||||
}
|
||||
if (host === "youtube.com" || host === "m.youtube.com") {
|
||||
const v = u.searchParams.get("v");
|
||||
if (v) return `https://www.youtube.com/embed/${v}`;
|
||||
let m = u.pathname.match(/\/embed\/([^/]+)/);
|
||||
if (m) return `https://www.youtube.com/embed/${m[1]}`;
|
||||
m = u.pathname.match(/\/shorts\/([^/]+)/);
|
||||
if (m) return `https://www.youtube.com/embed/${m[1]}`;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isDirectVideoFileUrl(url: string): boolean {
|
||||
const clean = url.split("?")[0].toLowerCase();
|
||||
return /^https?:\/\//.test(url.trim()) && /\.(mp4|webm|ogg|mov|m4v)$/.test(clean);
|
||||
}
|
||||
|
||||
export type VideoPreviewKind =
|
||||
| { kind: "iframe"; src: string; label: "Vimeo" | "YouTube" }
|
||||
| { kind: "video"; src: string }
|
||||
| { kind: "none" };
|
||||
|
||||
export function getVideoPreview(url: string): VideoPreviewKind {
|
||||
const t = url.trim();
|
||||
if (!t) return { kind: "none" };
|
||||
const vimeo = toVimeoEmbedUrl(t);
|
||||
if (vimeo) return { kind: "iframe", src: vimeo, label: "Vimeo" };
|
||||
const yt = toYoutubeEmbedUrl(t);
|
||||
if (yt) return { kind: "iframe", src: yt, label: "YouTube" };
|
||||
if (isDirectVideoFileUrl(t)) return { kind: "video", src: t };
|
||||
return { kind: "none" };
|
||||
}
|
||||
|
||||
/**
|
||||
* First N seconds only — embed “short” preview in admin cards / review, not the full file.
|
||||
* @see https://developers.google.com/youtube/player_parameters (end, start)
|
||||
*/
|
||||
export const DEFAULT_PREVIEW_MAX_SECONDS = 60;
|
||||
|
||||
export function formatPreviewLength(totalSeconds: number): string {
|
||||
if (totalSeconds < 60) return `${totalSeconds} seconds`;
|
||||
if (totalSeconds % 60 === 0) {
|
||||
const m = totalSeconds / 60;
|
||||
return m === 1 ? "1 minute" : `${m} minutes`;
|
||||
}
|
||||
return `${totalSeconds} seconds`;
|
||||
}
|
||||
|
||||
/**
|
||||
* YouTube: `end` = stop after this many seconds from the start of the video.
|
||||
* Vimeo: time range in URL fragment (supported on many vimeo.com player links).
|
||||
*/
|
||||
export function applyShortPreviewToEmbedUrl(
|
||||
embedUrl: string,
|
||||
label: "Vimeo" | "YouTube",
|
||||
maxSeconds: number = DEFAULT_PREVIEW_MAX_SECONDS,
|
||||
): string {
|
||||
try {
|
||||
if (label === "YouTube") {
|
||||
const u = new URL(embedUrl);
|
||||
u.searchParams.set("start", "0");
|
||||
u.searchParams.set("end", String(maxSeconds));
|
||||
u.searchParams.set("rel", u.searchParams.get("rel") ?? "0");
|
||||
return u.toString();
|
||||
}
|
||||
if (label === "Vimeo") {
|
||||
const u = new URL(embedUrl);
|
||||
u.searchParams.set("start", "0");
|
||||
u.searchParams.set("end", String(maxSeconds));
|
||||
u.hash = `t=0,${maxSeconds}`;
|
||||
return u.toString();
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return embedUrl;
|
||||
}
|
||||
|
||||
/** Google Drive "view" links are not direct image URLs; use the thumbnail API for preview. */
|
||||
export function resolveThumbnailForPreview(
|
||||
url: string | null | undefined,
|
||||
): string | null {
|
||||
if (!url?.trim()) return null;
|
||||
const t = url.trim();
|
||||
const m = t.match(/\/file\/d\/([a-zA-Z0-9_-]+)/);
|
||||
if (m) {
|
||||
return `https://drive.google.com/thumbnail?id=${m[1]}&sz=w800`;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
|
@ -67,9 +67,6 @@ export function LoginPage() {
|
|||
const navigate = useNavigate();
|
||||
|
||||
const token = localStorage.getItem("access_token");
|
||||
if (token) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
|
|
@ -162,6 +159,10 @@ export function LoginPage() {
|
|||
}
|
||||
}, [googleReady, handleGoogleCallback]);
|
||||
|
||||
if (token) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
|
|
|||
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,8 +1,10 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Check } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Stepper } from "../../components/ui/stepper";
|
||||
import { createModuleLesson } from "../../api/courses.api";
|
||||
|
||||
import { VideoDetailStep } from "./components/video-steps/VideoDetailStep";
|
||||
import { ReviewPublishStep } from "./components/video-steps/ReviewPublishStep";
|
||||
|
|
@ -13,6 +15,33 @@ const STEPS = [
|
|||
{ id: 2, label: "Review & Publish" },
|
||||
];
|
||||
|
||||
export type AddLessonFormData = {
|
||||
title: string;
|
||||
order: string;
|
||||
description: string;
|
||||
videoUrl: string;
|
||||
thumbnailUrl: string;
|
||||
};
|
||||
|
||||
const emptyForm = (): AddLessonFormData => ({
|
||||
title: "",
|
||||
order: "1",
|
||||
description: "",
|
||||
videoUrl: "",
|
||||
thumbnailUrl: "",
|
||||
});
|
||||
|
||||
function descriptionToApiPlain(html: string): string {
|
||||
if (!html?.trim()) return "";
|
||||
const t = html.trim();
|
||||
if (!t.includes("<")) return t;
|
||||
if (typeof document === "undefined") {
|
||||
return t.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
return doc.body.textContent?.replace(/\s+/g, " ").trim() ?? "";
|
||||
}
|
||||
|
||||
export function AddVideoFlow() {
|
||||
const navigate = useNavigate();
|
||||
const { level, courseId, moduleId } = useParams<{
|
||||
|
|
@ -22,24 +51,65 @@ export function AddVideoFlow() {
|
|||
}>();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [isPublished, setIsPublished] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: "",
|
||||
order: "1",
|
||||
description: "",
|
||||
thumbnail: null,
|
||||
videoFile: null,
|
||||
});
|
||||
const [formData, setFormData] = useState<AddLessonFormData>(emptyForm);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [formResetKey, setFormResetKey] = useState(0);
|
||||
|
||||
const nextStep = () => setCurrentStep((prev) => Math.min(prev + 1, 2));
|
||||
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
|
||||
const backPath = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
|
||||
|
||||
const handlePublish = async () => {
|
||||
const mid = Number(moduleId);
|
||||
if (!Number.isFinite(mid) || mid < 1) {
|
||||
toast.error("Invalid module");
|
||||
return;
|
||||
}
|
||||
const title = formData.title.trim();
|
||||
const videoUrl = formData.videoUrl.trim();
|
||||
const thumbnail = formData.thumbnailUrl.trim();
|
||||
if (!title) {
|
||||
toast.error("Title is required");
|
||||
return;
|
||||
}
|
||||
if (!videoUrl) {
|
||||
toast.error("Video URL is required");
|
||||
return;
|
||||
}
|
||||
if (!thumbnail) {
|
||||
toast.error("Thumbnail is required");
|
||||
return;
|
||||
}
|
||||
const description = descriptionToApiPlain(formData.description);
|
||||
if (!description) {
|
||||
toast.error("Description is required");
|
||||
return;
|
||||
}
|
||||
setPublishing(true);
|
||||
try {
|
||||
await createModuleLesson(mid, {
|
||||
title,
|
||||
video_url: videoUrl,
|
||||
thumbnail,
|
||||
description,
|
||||
});
|
||||
toast.success("Lesson created");
|
||||
setIsPublished(true);
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to create lesson";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isPublished) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center pb-20 animate-in fade-in zoom-in duration-500 ">
|
||||
{/* Success Icon Wrapper (Jagged Circle Style) */}
|
||||
<div className="mb-12 relative scale-110">
|
||||
<div className="absolute inset-0 bg-brand-500/5 blur-3xl rounded-full" />
|
||||
<div className="relative">
|
||||
|
|
@ -53,35 +123,37 @@ export function AddVideoFlow() {
|
|||
</div>
|
||||
|
||||
<h1 className="text-[26px] font-bold text-grayScale-900 mb-4">
|
||||
Video Published Successfully!
|
||||
Lesson created successfully
|
||||
</h1>
|
||||
<p className="text-grayScale-600 text-base mb-14 max-w-lg font-medium leading-relaxed">
|
||||
Your video is now live and available inside the selected module.
|
||||
Your lesson is now available in this module.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full max-w-[400px]">
|
||||
<Button
|
||||
onClick={() => navigate(`/new-content/learn-english/${level}`)}
|
||||
onClick={() => navigate(backPath)}
|
||||
className="h-12 rounded-[6px] bg-brand-500 font-bold text-[17px] text-white transition-all active:scale-95"
|
||||
>
|
||||
Go back to Learn English
|
||||
View module
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setFormData({
|
||||
title: "",
|
||||
order: "1",
|
||||
description: "",
|
||||
thumbnail: null,
|
||||
videoFile: null,
|
||||
});
|
||||
setFormData(emptyForm());
|
||||
setFormResetKey((k) => k + 1);
|
||||
setIsPublished(false);
|
||||
setCurrentStep(1);
|
||||
}}
|
||||
variant="outline"
|
||||
className="h-12 rounded-[6px] border-brand-200 text-brand-500 font-bold text-[17px] active:scale-95 bg-white"
|
||||
>
|
||||
Add Another Video
|
||||
Add another lesson
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate(`/new-content/learn-english/${level}/courses`)}
|
||||
variant="ghost"
|
||||
className="h-10 text-grayScale-600 font-medium"
|
||||
>
|
||||
All courses
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -90,7 +162,6 @@ export function AddVideoFlow() {
|
|||
|
||||
return (
|
||||
<div className="space-y-8 pb-32 px-6 pt-6 min-h-screen ">
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-7xl w-full">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<Link
|
||||
|
|
@ -98,7 +169,7 @@ export function AddVideoFlow() {
|
|||
className="flex items-center gap-2 text-[15px] font-medium text-grayScale-500 transition-colors hover:text-brand-500 decoration-none"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Modules
|
||||
Back to module
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -110,7 +181,7 @@ export function AddVideoFlow() {
|
|||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-[#0F172A] mb-10">
|
||||
Add New Video
|
||||
Add new lesson
|
||||
</h1>
|
||||
|
||||
<div className="mx-auto max-w-4xl mb-12">
|
||||
|
|
@ -120,13 +191,13 @@ export function AddVideoFlow() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="mx-auto max-w-7xl">
|
||||
{currentStep === 1 && (
|
||||
<VideoDetailStep
|
||||
key={formResetKey}
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
nextStep={nextStep}
|
||||
onContinue={nextStep}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -134,7 +205,8 @@ export function AddVideoFlow() {
|
|||
<ReviewPublishStep
|
||||
formData={formData}
|
||||
prevStep={prevStep}
|
||||
setIsPublished={setIsPublished}
|
||||
onPublish={() => void handlePublish()}
|
||||
publishing={publishing}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import {
|
|||
import { Textarea } from "../../components/ui/textarea"
|
||||
import { toast } from "sonner"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
||||
|
||||
type CourseWithCategory = Course & { category_name: string }
|
||||
|
||||
|
|
@ -230,10 +230,7 @@ export function AllCoursesPage() {
|
|||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<div className="rounded-2xl bg-white shadow-sm p-6">
|
||||
<SpinnerIcon className="h-10 w-10" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading all sub-categories…</p>
|
||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,8 @@
|
|||
import { NavLink, Outlet } from "react-router-dom"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const tabs = [
|
||||
{ label: "Overview", to: "/content" },
|
||||
{ label: "Courses", to: "/content/courses" },
|
||||
{ label: "Human Language", to: "/content/human-language" },
|
||||
{ label: "Flows", to: "/content/flows" },
|
||||
{ label: "Practice", to: "/content/practices" },
|
||||
{ label: "Questions", to: "/content/questions" },
|
||||
]
|
||||
import { Outlet } from "react-router-dom"
|
||||
|
||||
export function ContentManagementLayout() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-1 rounded-full bg-gradient-to-b from-brand-500 to-brand-600" />
|
||||
|
|
@ -22,38 +11,12 @@ export function ContentManagementLayout() {
|
|||
Content Management
|
||||
</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-relaxed text-grayScale-500">
|
||||
Manage courses, speaking exercises, practices, and questions
|
||||
View and manage practice content for courses, modules, and lessons
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div
|
||||
className="scroll-hide mb-8 flex items-center gap-1 overflow-x-auto rounded-2xl border border-grayScale-100 bg-grayScale-50/60 p-1.5 shadow-sm backdrop-blur"
|
||||
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
|
||||
>
|
||||
<style>{`.scroll-hide::-webkit-scrollbar { display: none; }`}</style>
|
||||
{tabs.map((t) => (
|
||||
<NavLink
|
||||
key={t.to}
|
||||
to={t.to}
|
||||
end={t.to === "/content"}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"relative whitespace-nowrap rounded-xl px-5 py-2 text-sm font-semibold transition-all duration-200 ease-in-out",
|
||||
"text-grayScale-500 hover:bg-white/80 hover:text-brand-600 hover:shadow-sm",
|
||||
isActive &&
|
||||
"bg-brand-500 text-white shadow-md shadow-brand-500/25 hover:bg-brand-600 hover:text-white",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { FolderOpen, RefreshCw, BookOpen, Plus } from "lucide-react"
|
||||
import { FolderOpen, RefreshCw, BookOpen, Plus, Trash2 } from "lucide-react"
|
||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
||||
import alertSrc from "../../assets/Alert.svg"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
|
|
@ -11,10 +11,11 @@ import {
|
|||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog"
|
||||
import { getCourseCategories, createCourseCategory } from "../../api/courses.api"
|
||||
import { getCourseCategories, createCourseCategory, deleteCourseCategory } from "../../api/courses.api"
|
||||
import type { CourseCategory } from "../../types/course.types"
|
||||
import { toast } from "sonner"
|
||||
|
||||
|
|
@ -29,6 +30,8 @@ export function CourseCategoryPage() {
|
|||
const [newSubCategoryName, setNewSubCategoryName] = useState("")
|
||||
const [pendingSubCategories, setPendingSubCategories] = useState<string[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [deleteTarget, setDeleteTarget] = useState<CourseCategory | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const fetchCategories = async () => {
|
||||
setLoading(true)
|
||||
|
|
@ -164,12 +167,26 @@ export function CourseCategoryPage() {
|
|||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
|
||||
View Sub-categories
|
||||
<span className="inline-block transition-transform duration-300 group-hover:translate-x-1">
|
||||
→
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
|
||||
View Sub-categories
|
||||
<span className="inline-block transition-transform duration-300 group-hover:translate-x-1">
|
||||
→
|
||||
</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>
|
||||
</Card>
|
||||
</Link>
|
||||
|
|
@ -335,7 +352,7 @@ export function CourseCategoryPage() {
|
|||
if (createdCategoryId && pendingSubCategories.length > 0) {
|
||||
await Promise.all(
|
||||
pendingSubCategories.map((subName) =>
|
||||
createCourseCategory({ name: subName }),
|
||||
createCourseCategory({ name: subName, parent_id: createdCategoryId }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -371,6 +388,46 @@ export function CourseCategoryPage() {
|
|||
</div>
|
||||
</DialogContent>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,166 +1,634 @@
|
|||
import { useState } from "react";
|
||||
import { ArrowLeft, Plus, Calendar, Plane, Clock, Hand } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Calendar,
|
||||
Layers,
|
||||
Pencil,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const MODULES = [
|
||||
{
|
||||
id: "m1",
|
||||
title: "Introduction Basics",
|
||||
description: "Learn basic English words, phrases, and simple sentences.",
|
||||
icon: Hand,
|
||||
status: "Published",
|
||||
gradient: "from-[#8E44AD] to-[#C39BD3]",
|
||||
},
|
||||
{
|
||||
id: "m2",
|
||||
title: "Daily Routines",
|
||||
description: "Vocabulary related to waking up, and evening activities.",
|
||||
icon: Clock,
|
||||
status: "Draft",
|
||||
gradient: "from-[#8E44AD] to-[#C39BD3]",
|
||||
},
|
||||
{
|
||||
id: "m3",
|
||||
title: "Travel Essentials",
|
||||
description:
|
||||
"Key phrases for airports, hotels, and asking for help while abroad.",
|
||||
icon: Plane,
|
||||
status: "Draft",
|
||||
gradient: "from-[#8E44AD] to-[#C39BD3]",
|
||||
},
|
||||
];
|
||||
|
||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
|
||||
import alertSrc from "../../assets/Alert.svg";
|
||||
import {
|
||||
deleteTopLevelCourseModule,
|
||||
getProgramCourses,
|
||||
getTopLevelCourseModules,
|
||||
updateTopLevelCourseModule,
|
||||
} from "../../api/courses.api";
|
||||
import { refreshFileUrl, resolveFileUrl } from "../../api/files.api";
|
||||
import type {
|
||||
ProgramCourseListItem,
|
||||
TopLevelCourseModuleItem,
|
||||
} from "../../types/course.types";
|
||||
import { AddModuleModal } from "./components/AddModuleModal";
|
||||
import { ModuleIconUploadField } from "./components/ModuleIconUploadField";
|
||||
|
||||
const MODULE_CARD_GRADIENT = "from-[#8E44AD] to-[#C39BD3]" as const;
|
||||
|
||||
function isLikelyImageUrl(src: string): boolean {
|
||||
const t = src.trim();
|
||||
return (
|
||||
t.startsWith("http://") ||
|
||||
t.startsWith("https://") ||
|
||||
t.startsWith("/") ||
|
||||
t.startsWith("data:")
|
||||
);
|
||||
}
|
||||
|
||||
function isSignedMinioUrl(src: string): boolean {
|
||||
const value = src.trim();
|
||||
if (!value.startsWith("http://") && !value.startsWith("https://"))
|
||||
return false;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.searchParams.has("X-Amz-Signature");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Default purple gradient with optional cover image; gradient stays if URL missing or image errors. */
|
||||
function ModuleCardTopMedia({ iconSrc }: { iconSrc: string }) {
|
||||
const [coverFailed, setCoverFailed] = useState(false);
|
||||
const tryCover =
|
||||
Boolean(iconSrc.trim()) && isLikelyImageUrl(iconSrc) && !coverFailed;
|
||||
|
||||
return (
|
||||
<div className="relative h-36 w-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 bg-gradient-to-b opacity-90 transition-transform duration-700",
|
||||
MODULE_CARD_GRADIENT,
|
||||
)}
|
||||
/>
|
||||
{tryCover ? (
|
||||
<img
|
||||
src={iconSrc.trim()}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
onError={() => setCoverFailed(true)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Circular module icon: image when load succeeds, otherwise default Layers icon. */
|
||||
function ModuleIconCircle({
|
||||
iconSrc,
|
||||
index,
|
||||
}: {
|
||||
iconSrc: string;
|
||||
index: number;
|
||||
}) {
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const showImg =
|
||||
Boolean(iconSrc.trim()) && isLikelyImageUrl(iconSrc) && !imgFailed;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-12 w-12 flex-shrink-0 items-center justify-center overflow-hidden rounded-full border border-purple-100/50 p-2",
|
||||
index % 2 === 1 ? "bg-[#F8FAFC]" : "bg-[#f3e8ff]",
|
||||
)}
|
||||
>
|
||||
{showImg ? (
|
||||
<img
|
||||
src={iconSrc.trim()}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
onError={() => setImgFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<Layers
|
||||
className={cn(
|
||||
"h-6 w-6",
|
||||
index % 2 === 1 ? "text-[#64748B]" : "text-brand-500",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CourseDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { level, courseId } = useParams<{ level: string; courseId: string }>();
|
||||
const { level: programIdParam, courseId: courseIdParam } = useParams<{
|
||||
level: string;
|
||||
courseId: string;
|
||||
}>();
|
||||
const programId = Number(programIdParam);
|
||||
const courseIdNum = Number(courseIdParam);
|
||||
|
||||
const [course, setCourse] = useState<ProgramCourseListItem | null>(null);
|
||||
const [modules, setModules] = useState<TopLevelCourseModuleItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isAddModuleOpen, setIsAddModuleOpen] = useState(false);
|
||||
|
||||
const [editingModule, setEditingModule] =
|
||||
useState<TopLevelCourseModuleItem | null>(null);
|
||||
const [editModuleName, setEditModuleName] = useState("");
|
||||
const [editModuleDescription, setEditModuleDescription] = useState("");
|
||||
const [editModuleIcon, setEditModuleIcon] = useState("");
|
||||
const [editModuleIconUploadBusy, setEditModuleIconUploadBusy] =
|
||||
useState(false);
|
||||
const [savingModuleEdit, setSavingModuleEdit] = useState(false);
|
||||
|
||||
const [deletingModule, setDeletingModule] =
|
||||
useState<TopLevelCourseModuleItem | null>(null);
|
||||
const [deletingModuleInFlight, setDeletingModuleInFlight] = useState(false);
|
||||
|
||||
const openEditModule = (module: TopLevelCourseModuleItem) => {
|
||||
setEditingModule(module);
|
||||
setEditModuleName(module.name ?? "");
|
||||
setEditModuleDescription(module.description ?? "");
|
||||
setEditModuleIcon(module.icon?.trim() ?? "");
|
||||
setEditModuleIconUploadBusy(false);
|
||||
};
|
||||
|
||||
const closeEditModule = () => {
|
||||
if (savingModuleEdit || editModuleIconUploadBusy) return;
|
||||
setEditingModule(null);
|
||||
setEditModuleIconUploadBusy(false);
|
||||
};
|
||||
|
||||
const loadPage = useCallback(async () => {
|
||||
if (!Number.isFinite(programId) || programId < 1) {
|
||||
setError("Invalid program");
|
||||
setCourse(null);
|
||||
setModules([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(courseIdNum) || courseIdNum < 1) {
|
||||
setError("Invalid course");
|
||||
setCourse(null);
|
||||
setModules([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [courseOutcome, modulesOutcome] = await Promise.allSettled([
|
||||
getProgramCourses(programId, { limit: 200, offset: 0 }),
|
||||
getTopLevelCourseModules(courseIdNum, { limit: 100, offset: 0 }),
|
||||
]);
|
||||
|
||||
if (courseOutcome.status === "fulfilled") {
|
||||
const raw = courseOutcome.value.data?.data?.courses;
|
||||
const list = Array.isArray(raw) ? raw : [];
|
||||
const found = list.find((c) => c.id === courseIdNum) ?? null;
|
||||
setCourse(found);
|
||||
if (!found) {
|
||||
setError("Course not found in this program");
|
||||
}
|
||||
} else {
|
||||
console.error(courseOutcome.reason);
|
||||
setCourse(null);
|
||||
setError("Failed to load course");
|
||||
}
|
||||
|
||||
if (modulesOutcome.status === "fulfilled") {
|
||||
const raw = modulesOutcome.value.data?.data?.modules;
|
||||
const list = Array.isArray(raw) ? raw : [];
|
||||
const refreshed = await Promise.all(
|
||||
list.map(async (module) => {
|
||||
const icon = module.icon?.trim() ?? "";
|
||||
if (!icon) return module;
|
||||
try {
|
||||
if (isSignedMinioUrl(icon)) {
|
||||
const refreshedRes = await refreshFileUrl(icon);
|
||||
const refreshedUrl = refreshedRes.data?.data?.url?.trim();
|
||||
if (refreshedUrl) {
|
||||
return { ...module, icon: refreshedUrl };
|
||||
}
|
||||
return module;
|
||||
}
|
||||
if (isLikelyImageUrl(icon)) return module;
|
||||
const resolved = await resolveFileUrl(icon);
|
||||
const freshUrl = resolved.data?.data?.url?.trim();
|
||||
if (!freshUrl) return module;
|
||||
return { ...module, icon: freshUrl };
|
||||
} catch {
|
||||
return module;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const sorted = [...refreshed].sort(
|
||||
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
|
||||
);
|
||||
setModules(sorted);
|
||||
} else {
|
||||
console.error(modulesOutcome.reason);
|
||||
setModules([]);
|
||||
toast.error("Could not load modules", {
|
||||
description: "Check your connection or try again.",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError("Failed to load course");
|
||||
setCourse(null);
|
||||
setModules([]);
|
||||
toast.error("Could not load course", {
|
||||
description: "Check your connection or try again.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [programId, courseIdNum]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPage();
|
||||
}, [loadPage]);
|
||||
|
||||
const handleSaveModuleEdit = async () => {
|
||||
if (!editingModule) return;
|
||||
const name = editModuleName.trim();
|
||||
if (!name) {
|
||||
toast.error("Module name is required");
|
||||
return;
|
||||
}
|
||||
setSavingModuleEdit(true);
|
||||
try {
|
||||
await updateTopLevelCourseModule(editingModule.id, {
|
||||
name,
|
||||
description: editModuleDescription.trim(),
|
||||
icon: editModuleIcon.trim(),
|
||||
});
|
||||
toast.success("Module updated");
|
||||
setEditModuleIconUploadBusy(false);
|
||||
setEditingModule(null);
|
||||
await loadPage();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to update module";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSavingModuleEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDeleteModule = async () => {
|
||||
if (!deletingModule) return;
|
||||
setDeletingModuleInFlight(true);
|
||||
try {
|
||||
await deleteTopLevelCourseModule(deletingModule.id);
|
||||
toast.success("Module deleted");
|
||||
setDeletingModule(null);
|
||||
await loadPage();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to delete module";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setDeletingModuleInFlight(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayTitle = course?.name?.trim() || courseIdParam || "Course";
|
||||
const displayDescription =
|
||||
course?.description?.trim() ||
|
||||
(!loading && !course
|
||||
? "This course could not be loaded."
|
||||
: !course?.description?.trim() && course
|
||||
? "—"
|
||||
: "");
|
||||
|
||||
return (
|
||||
<div className="space-y-10 pb-20 pt-10">
|
||||
{/* Header Navigation */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/new-content/learn-english/${level}/courses`}
|
||||
to={`/new-content/learn-english/${programIdParam}/courses`}
|
||||
className="flex items-center gap-2 text-sm font-medium text-grayScale-600 transition-colors hover:text-brand-500"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
Back to Levels
|
||||
Back to Courses
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Hero Section */}
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div className="">
|
||||
<h1 className="text-2xl font-medium text-grayScale-900 tracking-tight">
|
||||
{courseId?.toUpperCase() || "A1"}
|
||||
</h1>
|
||||
<p className="text-grayScale-500 text-sm max-w-2xl font-medium">
|
||||
Learn basic English words, phrases, and simple sentences for daily
|
||||
situations.
|
||||
</p>
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
) : error && !course ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-red-100 bg-red-50/60 px-6 py-14 text-center">
|
||||
<img src={alertSrc} alt="" className="h-10 w-10" />
|
||||
<p className="mt-3 text-sm font-medium text-red-700">{error}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-[6px] border-brand-500 text-brand-500 "
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/new-content/learn-english/${level}/courses/add-practice?backTo=modules&courseId=${courseId}`,
|
||||
)
|
||||
}
|
||||
className="mt-4"
|
||||
onClick={() => void loadPage()}
|
||||
>
|
||||
<Calendar className="h-4 w-4" />
|
||||
Add Practice
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"
|
||||
onClick={() => setIsAddModuleOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Module
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<AddModuleModal
|
||||
isOpen={isAddModuleOpen}
|
||||
onClose={() => setIsAddModuleOpen(false)}
|
||||
/>
|
||||
{/* Gradient Divider */}
|
||||
|
||||
{/* Gradient Grid */}
|
||||
<div className="flex flex-warp gap-10">
|
||||
{MODULES.map((module) => (
|
||||
<Card
|
||||
key={module.id}
|
||||
className="group overflow-hidden border w-[330px] border-grayScale-50 shadow-sm hover:shadow-lg transition-all duration-300 rounded-[16px] bg-white flex flex-col h-full"
|
||||
>
|
||||
{/* Gradient Banner */}
|
||||
) : (
|
||||
<>
|
||||
{/* Hero Section */}
|
||||
<div className="flex flex-col justify-between gap-6 md:flex-row md:items-end">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-2xl font-medium tracking-tight text-grayScale-900">
|
||||
{displayTitle}
|
||||
</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm font-medium text-grayScale-500">
|
||||
{displayDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-[6px] border-brand-500 text-brand-500 "
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/new-content/learn-english/${programIdParam}/courses/add-practice?backTo=modules&courseId=${courseIdParam}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Calendar className="h-4 w-4" />
|
||||
Add Practice
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"
|
||||
onClick={() => setIsAddModuleOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Module
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
"h-36 w-full bg-gradient-to-b opacity-90 transition-transform duration-700",
|
||||
module.gradient,
|
||||
)}
|
||||
/>
|
||||
className="absolute inset-0 flex items-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="w-full border-t border-grayScale-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<div
|
||||
className="h-[0.5px] w-full rounded-full opacity-20"
|
||||
style={{
|
||||
background: "gray",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-2 pb-4 pt-4 flex-1 flex flex-col">
|
||||
<div className="flex gap-4 mb-8">
|
||||
{/* Icon Circle */}
|
||||
<div
|
||||
className={`h-12 w-12 rounded-full ${module.id === "m2" ? "bg-[#F8FAFC]" : "bg-[#f3e8ff]"} flex items-center justify-center p-3 flex-shrink-0 border border-purple-100/50`}
|
||||
>
|
||||
<module.icon
|
||||
className={`h-6 w-6 ${module.id === "m2" ? "text-[#64748B]" : "text-brand-500"}`}
|
||||
<AddModuleModal
|
||||
isOpen={isAddModuleOpen}
|
||||
onClose={() => setIsAddModuleOpen(false)}
|
||||
courseId={courseIdNum}
|
||||
onCreated={() => loadPage()}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={editingModule !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && savingModuleEdit) return;
|
||||
if (!open && editModuleIconUploadBusy) return;
|
||||
if (!open) closeEditModule();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit module</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update name, description, and icon (upload or URL). Saved with{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||
PUT /modules/:id
|
||||
</code>
|
||||
.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
value={editModuleName}
|
||||
onChange={(e) => setEditModuleName(e.target.value)}
|
||||
className="rounded-xl"
|
||||
placeholder="e.g. Grammar basics"
|
||||
disabled={savingModuleEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
value={editModuleDescription}
|
||||
onChange={(e) => setEditModuleDescription(e.target.value)}
|
||||
rows={4}
|
||||
className="min-h-[100px] resize-y rounded-xl"
|
||||
placeholder="Optional short description."
|
||||
disabled={savingModuleEdit}
|
||||
/>
|
||||
</div>
|
||||
<ModuleIconUploadField
|
||||
value={editModuleIcon}
|
||||
onChange={setEditModuleIcon}
|
||||
disabled={savingModuleEdit}
|
||||
onUploadBusyChange={setEditModuleIconUploadBusy}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={closeEditModule}
|
||||
disabled={savingModuleEdit || editModuleIconUploadBusy}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-brand-500 hover:bg-brand-600"
|
||||
disabled={savingModuleEdit || editModuleIconUploadBusy}
|
||||
onClick={() => void handleSaveModuleEdit()}
|
||||
>
|
||||
{savingModuleEdit ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-bold text-[#0F172A] tracking-tight">
|
||||
{module.title}
|
||||
</h3>
|
||||
<p className="text-grayScale-400 font-medium text-[12px]">
|
||||
{module.description}
|
||||
{modules.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 modules in this course yet
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-grayScale-400">
|
||||
Add modules when your workflow is connected, or create them via
|
||||
the API.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid justify-start gap-10"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(330px, 330px))",
|
||||
}}
|
||||
>
|
||||
{modules.map((module, index) => {
|
||||
const iconSrc = module.icon?.trim() ?? "";
|
||||
return (
|
||||
<Card
|
||||
key={module.id}
|
||||
className="group relative flex h-full min-h-0 w-full flex-col overflow-hidden rounded-[16px] border border-grayScale-50 bg-white shadow-sm transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
<div className="absolute right-2 top-2 z-10 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
|
||||
aria-label={`Edit ${module.name}`}
|
||||
onClick={() => openEditModule(module)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
|
||||
aria-label={`Delete ${module.name}`}
|
||||
onClick={() => setDeletingModule(module)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<ModuleCardTopMedia iconSrc={iconSrc} />
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6 p-2 pb-4 pt-4">
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
<ModuleIconCircle iconSrc={iconSrc} index={index} />
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<h3 className="text-lg font-bold tracking-tight text-[#0F172A]">
|
||||
{module.name}
|
||||
</h3>
|
||||
<p className="text-[12px] font-medium leading-snug text-grayScale-400 line-clamp-3">
|
||||
{module.description?.trim()
|
||||
? module.description
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex shrink-0 items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 flex-1 rounded-[6px] border-[#9E2891] text-sm text-[#9E2891] transition-all"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/new-content/learn-english/${programIdParam}/courses/${courseIdParam}/modules/${module.id}`,
|
||||
{
|
||||
state: {
|
||||
moduleName: module.name,
|
||||
moduleDescription:
|
||||
module.description?.trim() ?? "",
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
View Detail
|
||||
</Button>
|
||||
<Button className="h-10 flex-1 rounded-[6px] bg-brand-500 text-sm text-white shadow-md shadow-brand-500/10">
|
||||
Publish Practice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deletingModule && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
|
||||
<h2 className="text-lg font-bold text-grayScale-700">
|
||||
Delete module
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
!deletingModuleInFlight && setDeletingModule(null)
|
||||
}
|
||||
disabled={deletingModuleInFlight}
|
||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-6">
|
||||
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
|
||||
<Trash2 className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<p className="text-center text-sm leading-relaxed text-grayScale-600">
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-grayScale-700">
|
||||
{deletingModule.name}
|
||||
</span>
|
||||
? This cannot be undone. Related content may be affected
|
||||
depending on your backend.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3 mt-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 h-10 rounded-[6px] border-[#9E2891] text-[#9E2891] transition-all text-sm"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/new-content/learn-english/${level}/courses/${courseId}/modules/${module.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
View Detail
|
||||
</Button>
|
||||
{module.status === "Published" ? (
|
||||
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
disabled
|
||||
className="flex-1 h-10 rounded-[6px] bg-[#D291BC] text-white opacity-100 cursor-default border-none shadow-none text-sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDeletingModule(null)}
|
||||
disabled={deletingModuleInFlight}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Published
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="flex-1 h-10 rounded-[6px] bg-brand-500 text-white shadow-md shadow-brand-500/10 text-sm">
|
||||
Publish Practice
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full bg-red-500 shadow-sm transition-all hover:bg-red-600 hover:shadow-md sm:w-auto"
|
||||
disabled={deletingModuleInFlight}
|
||||
onClick={() => void handleConfirmDeleteModule()}
|
||||
>
|
||||
{deletingModuleInFlight ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
BadgeCheck,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
GripVertical,
|
||||
|
|
@ -32,9 +31,9 @@ import { Badge } from "../../components/ui/badge"
|
|||
import {
|
||||
getCourseCategories,
|
||||
getCoursesByCategory,
|
||||
getLearningPath,
|
||||
getSubModulesByCourse,
|
||||
getVideosBySubModule,
|
||||
getQuestionSetsByOwner,
|
||||
getSubModuleEntryAssessment,
|
||||
reorderCategories,
|
||||
reorderCourses,
|
||||
reorderSubModules,
|
||||
|
|
@ -194,9 +193,7 @@ export function CourseFlowBuilderPage() {
|
|||
const [practicesBySubCourse, setPracticesBySubCourse] = useState<Record<number, PracticeListItem[]>>(
|
||||
{},
|
||||
)
|
||||
const [entryAssessmentBySubCourse, setEntryAssessmentBySubCourse] = useState<Record<number, QuestionSet | null>>(
|
||||
{},
|
||||
)
|
||||
const [videosBySubCourse, setVideosBySubCourse] = useState<Record<number, LearningPathVideo[]>>({})
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingCourses, setLoadingCourses] = useState(false)
|
||||
|
|
@ -260,7 +257,9 @@ export function CourseFlowBuilderPage() {
|
|||
setLoadingCourses(true)
|
||||
try {
|
||||
const res = await getCoursesByCategory(selectedCategoryId)
|
||||
const items = sortByDisplayOrder(res.data.data.courses ?? [])
|
||||
const items = sortByDisplayOrder(
|
||||
(res.data.data.courses ?? []).filter((course) => Number(course.category_id) === Number(selectedCategoryId)),
|
||||
)
|
||||
setCoursesByCategory((prev) => ({ ...prev, [selectedCategoryId]: items }))
|
||||
setSelectedCourseId(items[0]?.id ?? null)
|
||||
} catch {
|
||||
|
|
@ -280,47 +279,94 @@ export function CourseFlowBuilderPage() {
|
|||
const load = async () => {
|
||||
setLoadingPath(true)
|
||||
try {
|
||||
const res = await getLearningPath(selectedCourseId)
|
||||
const path = res.data.data
|
||||
const selectedCourse = activeCourses.find((course) => course.id === selectedCourseId)
|
||||
const subRes = await getSubModulesByCourse(selectedCourseId)
|
||||
const subCourses = sortByDisplayOrder((subRes.data.data.sub_courses ?? []) as any[]).map((sc) => ({
|
||||
id: sc.id,
|
||||
title: sc.title,
|
||||
description: sc.description ?? "",
|
||||
thumbnail: sc.thumbnail ?? "",
|
||||
display_order: sc.display_order ?? 0,
|
||||
level: sc.level ?? sc.cefr_level ?? "",
|
||||
sub_level: sc.sub_level ?? "",
|
||||
prerequisite_count: 0,
|
||||
video_count: 0,
|
||||
practice_count: 0,
|
||||
prerequisites: [],
|
||||
videos: [],
|
||||
practices: [],
|
||||
}))
|
||||
|
||||
setLearningPath({
|
||||
...path,
|
||||
sub_courses: sortByDisplayOrder(path.sub_courses ?? []),
|
||||
course_id: selectedCourseId,
|
||||
course_title: selectedCourse?.title ?? "",
|
||||
description: selectedCourse?.description ?? "",
|
||||
thumbnail: selectedCourse?.thumbnail ?? "",
|
||||
intro_video_url: "",
|
||||
category_id: selectedCategoryId ?? 0,
|
||||
category_name: topLevelCategories.find((cat) => cat.id === selectedCategoryId)?.name ?? "",
|
||||
sub_courses: subCourses,
|
||||
})
|
||||
|
||||
// Practices source of truth: question sets by SUB_COURSE owner.
|
||||
const subCourses = path.sub_courses ?? []
|
||||
if (subCourses.length > 0) {
|
||||
const ownerResults = await Promise.all(
|
||||
if (subCourses.length === 0) {
|
||||
setPracticesBySubCourse({})
|
||||
setVideosBySubCourse({})
|
||||
return
|
||||
}
|
||||
|
||||
const [ownerResults, videoResults] = await Promise.all([
|
||||
Promise.all(
|
||||
subCourses.map(async (sc) => {
|
||||
const setsRes = await getQuestionSetsByOwner("SUB_COURSE", sc.id)
|
||||
const setsRes = await getQuestionSetsByOwner("SUB_MODULE", sc.id)
|
||||
return [sc.id, mapPracticeSetsToPracticeItems((setsRes.data.data ?? []) as QuestionSet[])] as const
|
||||
}),
|
||||
)
|
||||
const practiceMap: Record<number, PracticeListItem[]> = {}
|
||||
ownerResults.forEach(([subCourseId, practiceItems]) => {
|
||||
practiceMap[subCourseId] = practiceItems
|
||||
})
|
||||
setPracticesBySubCourse(practiceMap)
|
||||
} else {
|
||||
setPracticesBySubCourse({})
|
||||
}
|
||||
),
|
||||
Promise.all(
|
||||
subCourses.map(async (sc) => {
|
||||
const videosRes = await getVideosBySubModule(sc.id)
|
||||
const rows = videosRes.data?.data?.videos ?? []
|
||||
const mapped = sortByDisplayOrder(
|
||||
rows.map((video: any, idx: number) => ({
|
||||
id: Number(video.id),
|
||||
title: String(video.title ?? "Video"),
|
||||
display_order: Number(video.display_order ?? idx),
|
||||
duration: Number(video.duration ?? 0),
|
||||
video_url: String(video.video_url ?? ""),
|
||||
})),
|
||||
)
|
||||
return [sc.id, mapped] as const
|
||||
}),
|
||||
),
|
||||
])
|
||||
|
||||
const practiceMap: Record<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 {
|
||||
toast.error("Failed to load course sub-category learning path.")
|
||||
toast.error("Failed to load course flow detail.")
|
||||
setLearningPath(null)
|
||||
} finally {
|
||||
setLoadingPath(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [selectedCourseId])
|
||||
}, [selectedCourseId, activeCourses, selectedCategoryId, topLevelCategories])
|
||||
|
||||
const loadSubCoursePracticeAndEntry = async (subCourseId: number) => {
|
||||
if (practicesBySubCourse[subCourseId] && entryAssessmentBySubCourse[subCourseId] !== undefined) return
|
||||
if (practicesBySubCourse[subCourseId] && videosBySubCourse[subCourseId]) return
|
||||
setLoadingPracticesBySubCourse((prev) => ({ ...prev, [subCourseId]: true }))
|
||||
try {
|
||||
const [setsRes, entryRes] = await Promise.allSettled([
|
||||
getQuestionSetsByOwner("SUB_COURSE", subCourseId),
|
||||
getSubModuleEntryAssessment(subCourseId),
|
||||
const [setsRes, videosRes] = await Promise.allSettled([
|
||||
getQuestionSetsByOwner("SUB_MODULE", subCourseId),
|
||||
getVideosBySubModule(subCourseId),
|
||||
])
|
||||
|
||||
// No practice sets is a valid empty-state scenario; do not toast for 404/empty.
|
||||
|
|
@ -339,20 +385,21 @@ export function CourseFlowBuilderPage() {
|
|||
[subCourseId]: mapPracticeSetsToPracticeItems(ownerSets),
|
||||
}))
|
||||
|
||||
// Entry assessment may legitimately be absent.
|
||||
let entryAssessment: QuestionSet | null = null
|
||||
if (entryRes.status === "fulfilled") {
|
||||
entryAssessment = (entryRes.value.data.data ?? null) as QuestionSet | null
|
||||
} else {
|
||||
const status = entryRes.reason?.response?.status
|
||||
if (status !== 404) {
|
||||
throw entryRes.reason
|
||||
}
|
||||
}
|
||||
|
||||
setEntryAssessmentBySubCourse((prev) => ({
|
||||
const videos =
|
||||
videosRes.status === "fulfilled"
|
||||
? sortByDisplayOrder(
|
||||
(videosRes.value.data?.data?.videos ?? []).map((video: any, idx: number) => ({
|
||||
id: Number(video.id),
|
||||
title: String(video.title ?? "Video"),
|
||||
display_order: Number(video.display_order ?? idx),
|
||||
duration: Number(video.duration ?? 0),
|
||||
video_url: String(video.video_url ?? ""),
|
||||
})),
|
||||
)
|
||||
: []
|
||||
setVideosBySubCourse((prev) => ({
|
||||
...prev,
|
||||
[subCourseId]: entryAssessment,
|
||||
[subCourseId]: videos,
|
||||
}))
|
||||
} catch {
|
||||
toast.error("Failed to load practice sets for course.")
|
||||
|
|
@ -694,6 +741,7 @@ export function CourseFlowBuilderPage() {
|
|||
{learningPath.sub_courses.map((subCourse) => {
|
||||
const expanded = expandedSubCourseIds.has(subCourse.id)
|
||||
const practices = practicesBySubCourse[subCourse.id] ?? []
|
||||
const videos = videosBySubCourse[subCourse.id] ?? subCourse.videos ?? []
|
||||
return (
|
||||
<SortableRow key={subCourse.id} id={subCourse.id}>
|
||||
<button
|
||||
|
|
@ -723,17 +771,12 @@ export function CourseFlowBuilderPage() {
|
|||
{subCourse.sub_level}
|
||||
</Badge>
|
||||
)}
|
||||
{entryAssessmentBySubCourse[subCourse.id] && (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-600">
|
||||
<BadgeCheck className="h-3.5 w-3.5" />
|
||||
Entry assessment
|
||||
</span>
|
||||
)}
|
||||
{/* entry-assessment route is no longer guaranteed across deployments */}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-end">
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{subCourse.videos.length} videos / {practices.length || subCourse.practice_count} practices
|
||||
{videos.length} videos / {practices.length} practices
|
||||
</Badge>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-grayScale-400" />
|
||||
|
|
@ -755,16 +798,16 @@ export function CourseFlowBuilderPage() {
|
|||
onDragEnd={(event) => onVideosDragEnd(subCourse.id, event)}
|
||||
>
|
||||
<SortableContext
|
||||
items={subCourse.videos.map((item) => item.id)}
|
||||
items={videos.map((item) => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<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">
|
||||
No videos
|
||||
</p>
|
||||
) : (
|
||||
subCourse.videos.map((video) => (
|
||||
videos.map((video) => (
|
||||
<SortableChip
|
||||
key={video.id}
|
||||
id={video.id}
|
||||
|
|
@ -842,7 +885,7 @@ export function CourseFlowBuilderPage() {
|
|||
</p>
|
||||
<p>
|
||||
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>
|
||||
</CardContent>
|
||||
</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
|
|
@ -1,5 +1,7 @@
|
|||
import { Plus, ArrowRight } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Plus, ArrowRight, Pencil, Trash2, X } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardContent } from "../../components/ui/card";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
|
|
@ -9,33 +11,250 @@ import {
|
|||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogFooter,
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Select } from "../../components/ui/select";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import uploadIcon from "../../assets/icons/upload.png";
|
||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
|
||||
import alertSrc from "../../assets/Alert.svg";
|
||||
import {
|
||||
getLearningPrograms,
|
||||
createLearningProgram,
|
||||
updateLearningProgram,
|
||||
deleteLearningProgram,
|
||||
} from "../../api/courses.api";
|
||||
import { uploadImageFile } from "../../api/files.api";
|
||||
import type { LearningProgramListItem } from "../../types/course.types";
|
||||
|
||||
export function LearnEnglishPage() {
|
||||
const levels = [
|
||||
{
|
||||
id: "beginner",
|
||||
title: "Beginner",
|
||||
description:
|
||||
"Designed for learners starting from scratch. Focuses on simple grammar, and everyday communication.",
|
||||
},
|
||||
{
|
||||
id: "intermediate",
|
||||
title: "Intermediate",
|
||||
description:
|
||||
"For learners who can communicate at a basic level and want to improve fluency, accuracy, and confidence.",
|
||||
},
|
||||
{
|
||||
id: "advanced",
|
||||
title: "Advanced",
|
||||
description:
|
||||
"Targets advanced learners aiming for professional, academic, and complex conversational English.",
|
||||
},
|
||||
];
|
||||
const [programs, setPrograms] = useState<LearningProgramListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [editingProgram, setEditingProgram] =
|
||||
useState<LearningProgramListItem | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [editThumbnail, setEditThumbnail] = useState("");
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
||||
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [createDescription, setCreateDescription] = useState("");
|
||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
||||
const [createSaving, setCreateSaving] = useState(false);
|
||||
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
|
||||
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [deletingProgram, setDeletingProgram] =
|
||||
useState<LearningProgramListItem | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const openEdit = (program: LearningProgramListItem) => {
|
||||
setEditingProgram(program);
|
||||
setEditName(program.name ?? "");
|
||||
setEditDescription(program.description?.trim() ?? "");
|
||||
setEditThumbnail(program.thumbnail?.trim() ?? "");
|
||||
};
|
||||
|
||||
const closeEdit = () => {
|
||||
setEditingProgram(null);
|
||||
setEditName("");
|
||||
setEditDescription("");
|
||||
setEditThumbnail("");
|
||||
setUploadingEditThumbnail(false);
|
||||
if (editThumbnailFileInputRef.current) editThumbnailFileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const handleEditThumbnailFile = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error("Please choose an image file");
|
||||
return;
|
||||
}
|
||||
const maxBytes = 5 * 1024 * 1024;
|
||||
if (file.size > maxBytes) {
|
||||
toast.error("Image is too large", { description: "Maximum size is 5 MB." });
|
||||
return;
|
||||
}
|
||||
setUploadingEditThumbnail(true);
|
||||
try {
|
||||
const res = await uploadImageFile(file);
|
||||
const url = res.data?.data?.url?.trim();
|
||||
if (!url) {
|
||||
throw new Error("Upload did not return a file URL");
|
||||
}
|
||||
setEditThumbnail(url);
|
||||
toast.success("Thumbnail uploaded");
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to upload thumbnail";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setUploadingEditThumbnail(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCreateFormFields = () => {
|
||||
setCreateName("");
|
||||
setCreateDescription("");
|
||||
setCreateThumbnail("");
|
||||
if (createThumbnailFileInputRef.current) {
|
||||
createThumbnailFileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateDialogOpenChange = (open: boolean) => {
|
||||
if (!open && (createSaving || createUploadingThumbnail)) return;
|
||||
clearCreateFormFields();
|
||||
setCreateOpen(open);
|
||||
};
|
||||
|
||||
const handleCreateThumbnailFile = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error("Please choose an image file");
|
||||
return;
|
||||
}
|
||||
const maxBytes = 5 * 1024 * 1024;
|
||||
if (file.size > maxBytes) {
|
||||
toast.error("Image is too large", { description: "Maximum size is 5 MB." });
|
||||
return;
|
||||
}
|
||||
setCreateUploadingThumbnail(true);
|
||||
try {
|
||||
const res = await uploadImageFile(file);
|
||||
const url = res.data?.data?.url?.trim();
|
||||
if (!url) {
|
||||
throw new Error("Upload did not return a file URL");
|
||||
}
|
||||
setCreateThumbnail(url);
|
||||
toast.success("Thumbnail uploaded");
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to upload thumbnail";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setCreateUploadingThumbnail(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateProgram = async () => {
|
||||
const name = createName.trim();
|
||||
if (!name) {
|
||||
toast.error("Program name is required");
|
||||
return;
|
||||
}
|
||||
setCreateSaving(true);
|
||||
try {
|
||||
await createLearningProgram({
|
||||
name,
|
||||
description: createDescription.trim(),
|
||||
thumbnail: createThumbnail.trim(),
|
||||
});
|
||||
toast.success("Program created");
|
||||
clearCreateFormFields();
|
||||
setCreateOpen(false);
|
||||
await fetchPrograms();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to create program";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setCreateSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingProgram) return;
|
||||
const name = editName.trim();
|
||||
if (!name) {
|
||||
toast.error("Program name is required");
|
||||
return;
|
||||
}
|
||||
setSavingEdit(true);
|
||||
try {
|
||||
await updateLearningProgram(editingProgram.id, {
|
||||
name,
|
||||
description: editDescription.trim(),
|
||||
thumbnail: editThumbnail.trim(),
|
||||
});
|
||||
toast.success("Program updated");
|
||||
closeEdit();
|
||||
await fetchPrograms();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to update program";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSavingEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deletingProgram) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteLearningProgram(deletingProgram.id);
|
||||
toast.success("Program deleted");
|
||||
setDeletingProgram(null);
|
||||
await fetchPrograms();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to delete program";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPrograms = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await getLearningPrograms({ limit: 100, offset: 0 });
|
||||
const raw = res.data?.data?.programs;
|
||||
const list = Array.isArray(raw) ? raw : [];
|
||||
const sorted = [...list].sort(
|
||||
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
|
||||
);
|
||||
setPrograms(sorted);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError("Failed to load programs");
|
||||
setPrograms([]);
|
||||
toast.error("Could not load programs", {
|
||||
description: "Check your connection or try again.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchPrograms();
|
||||
}, [fetchPrograms]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
|
|
@ -46,115 +265,163 @@ export function LearnEnglishPage() {
|
|||
Learn English
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-grayScale-500">
|
||||
Manage learning content by level
|
||||
Manage learning content by program — cards load from the server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog>
|
||||
<Dialog open={createOpen} onOpenChange={handleCreateDialogOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="h-11 rounded-[6px] bg-brand-500 px-6 font-semibold ">
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
Add Program
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl gap-0 border-none p-0">
|
||||
<DialogHeader className="p-8 pb-4">
|
||||
<DialogTitle className="text-2xl font-bold text-grayScale-700">
|
||||
Add New Program
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-400">
|
||||
Create a learning program to group courses by learner level
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* Gradient Divider */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-0 flex items-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="w-full border-t border-grayScale-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-2xl flex-col gap-0 overflow-hidden border-none p-0">
|
||||
<div className="shrink-0">
|
||||
<DialogHeader className="p-8 pb-4">
|
||||
<DialogTitle className="text-2xl font-bold text-grayScale-700">
|
||||
Add New Program
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-400">
|
||||
Create a learning program via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
||||
POST /programs
|
||||
</code>
|
||||
. Thumbnail can be a URL or a file uploaded through{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
||||
POST /files/upload
|
||||
</code>
|
||||
.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* Gradient Divider */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
||||
style={{
|
||||
background: "gray",
|
||||
}}
|
||||
/>
|
||||
className="absolute inset-0 flex items-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="w-full border-t border-grayScale-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<div
|
||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
||||
style={{
|
||||
background: "gray",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="space-y-6 p-8 pt-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] text-grayScale-700">
|
||||
Program Name
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Beginner"
|
||||
className="h-12 rounded-xl ring-0"
|
||||
/>
|
||||
</div>
|
||||
<form
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void handleCreateProgram();
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain px-8 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] text-grayScale-700">
|
||||
Program Name
|
||||
</label>
|
||||
<Input
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
placeholder="e.g. Intermediate Track"
|
||||
className="h-12 rounded-xl ring-0"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] text-grayScale-700">
|
||||
Description
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Short description explaining who this program is for"
|
||||
className="h-12 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] text-grayScale-700">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
value={createDescription}
|
||||
onChange={(e) => setCreateDescription(e.target.value)}
|
||||
placeholder="Short summary of the program"
|
||||
rows={3}
|
||||
className="min-h-[88px] resize-y rounded-xl"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] text-grayScale-700">
|
||||
Program Order
|
||||
</label>
|
||||
<Select className="h-12 rounded-xl">
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] text-grayScale-700">
|
||||
Thumbnail
|
||||
</label>
|
||||
<div className="relative group cursor-pointer">
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-[#9E289133] bg-white p-10 transition-all ">
|
||||
<div className="mb-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] text-grayScale-700">
|
||||
Thumbnail
|
||||
</label>
|
||||
<input
|
||||
ref={createThumbnailFileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="sr-only"
|
||||
onChange={(e) => void handleCreateThumbnailFile(e)}
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="relative w-full cursor-pointer rounded-2xl border-2 border-dashed border-[#9E289133] bg-white p-10 text-left transition-all hover:border-[#9E289180] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
onClick={() => createThumbnailFileInputRef.current?.click()}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="mb-4">
|
||||
<img
|
||||
src={uploadIcon}
|
||||
alt=""
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
<span className="font-bold text-[#9E2891]">
|
||||
{createUploadingThumbnail ? "Uploading…" : "Click to upload"}
|
||||
</span>{" "}
|
||||
<span className="text-grayScale-500">
|
||||
or paste a URL below
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-medium text-grayScale-400 uppercase tracking-wider">
|
||||
JPG, PNG (max 5 MB)
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{createThumbnail.trim() ? (
|
||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
||||
<img
|
||||
src={uploadIcon}
|
||||
alt="Upload icon"
|
||||
className="h-10 w-10"
|
||||
src={createThumbnail.trim()}
|
||||
alt=""
|
||||
className="h-28 w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
<span className="font-bold text-[#9E2891]">
|
||||
Click to upload
|
||||
</span>{" "}
|
||||
<span className="text-grayScale-500">
|
||||
or drag and drop
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-medium text-grayScale-400 uppercase tracking-wider">
|
||||
JPG, PNG (MAX 1 MB)
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<Input
|
||||
value={createThumbnail}
|
||||
onChange={(e) => setCreateThumbnail(e.target.value)}
|
||||
className="h-12 rounded-xl"
|
||||
placeholder="https://…"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-12 min-w-[120px] rounded-[6px] border-grayScale-200 font-semibold"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button className="h-12 min-w-[160px] rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600">
|
||||
Create Program
|
||||
<div className="flex shrink-0 justify-end gap-3 border-t border-grayScale-100 bg-white px-8 py-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12 min-w-[120px] rounded-[6px] border-grayScale-200 font-semibold"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
onClick={() => handleCreateDialogOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-12 min-w-[160px] rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
>
|
||||
{createSaving ? "Creating…" : "Create Program"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -177,40 +444,263 @@ export function LearnEnglishPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
<div className="flex flex-warp gap-10">
|
||||
{levels.map((level) => (
|
||||
<Card
|
||||
key={level.title}
|
||||
className="group w-[290px] overflow-hidden border-none shadow-soft transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
||||
<p className="mt-3 text-sm text-grayScale-500">Loading programs…</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-red-100 bg-red-50/60 px-6 py-14 text-center">
|
||||
<img src={alertSrc} alt="" className="h-10 w-10" />
|
||||
<p className="mt-3 text-sm font-medium text-red-700">{error}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => void fetchPrograms()}
|
||||
>
|
||||
{/* Gradient Header */}
|
||||
<div
|
||||
className="h-32 w-full"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, #9E289180 0%, #9E2891 100%)",
|
||||
}}
|
||||
/>
|
||||
<CardContent className="bg-white p-6 flex flex-col h-[280px]">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-bold text-grayScale-700">
|
||||
{level.title}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-grayScale-500">
|
||||
{level.description}
|
||||
</p>
|
||||
</div>
|
||||
<Link to={`/new-content/learn-english/${level.id}/courses`}>
|
||||
<Button className="h-11 w-full rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600">
|
||||
View Courses
|
||||
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : programs.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 programs yet
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-grayScale-400">
|
||||
Add programs in the backend or use Add Program when it is connected.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-10">
|
||||
{programs.map((program) => (
|
||||
<Card
|
||||
key={program.id}
|
||||
className="group relative w-[290px] overflow-hidden border-none shadow-soft transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||
>
|
||||
<div
|
||||
className="absolute right-2 top-2 z-10 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
|
||||
aria-label={`Edit ${program.name}`}
|
||||
onClick={() => openEdit(program)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
|
||||
aria-label={`Delete ${program.name}`}
|
||||
onClick={() => setDeletingProgram(program)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="h-32 w-full bg-cover bg-center"
|
||||
style={
|
||||
program.thumbnail?.trim()
|
||||
? {
|
||||
backgroundImage: `url(${program.thumbnail.trim()})`,
|
||||
}
|
||||
: {
|
||||
background:
|
||||
"linear-gradient(135deg, #9E289180 0%, #9E2891 100%)",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<CardContent className="bg-white p-6 flex flex-col h-[280px]">
|
||||
<div className="flex-1 min-h-0">
|
||||
<h3 className="text-xl font-bold text-grayScale-700 line-clamp-2">
|
||||
{program.name}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-grayScale-500 line-clamp-4">
|
||||
{program.description?.trim()
|
||||
? program.description
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to={`/new-content/learn-english/${program.id}/courses`}
|
||||
className="mt-4 block"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button className="h-11 w-full rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600">
|
||||
View Courses
|
||||
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={editingProgram !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && (savingEdit || uploadingEditThumbnail)) return;
|
||||
if (!open) closeEdit();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit program</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update name, description, and thumbnail. Upload an image from your
|
||||
computer (via file storage) or paste a URL. Changes are saved to the
|
||||
server.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
className="rounded-xl"
|
||||
placeholder="Program name"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
rows={4}
|
||||
className="rounded-xl resize-y min-h-[100px]"
|
||||
placeholder="Short summary of the program"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Thumbnail
|
||||
</label>
|
||||
<input
|
||||
ref={editThumbnailFileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="sr-only"
|
||||
onChange={(e) => void handleEditThumbnailFile(e)}
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-11 shrink-0 rounded-xl border-grayScale-200 font-semibold"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
onClick={() => editThumbnailFileInputRef.current?.click()}
|
||||
>
|
||||
{uploadingEditThumbnail ? "Uploading…" : "Upload from computer"}
|
||||
</Button>
|
||||
{editThumbnail.trim() ? (
|
||||
<div className="flex-1 overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
||||
<img
|
||||
src={editThumbnail.trim()}
|
||||
alt=""
|
||||
className="h-24 w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Input
|
||||
value={editThumbnail}
|
||||
onChange={(e) => setEditThumbnail(e.target.value)}
|
||||
className="rounded-xl"
|
||||
placeholder="Or paste image URL (https://…)"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Local images are sent to{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||
POST /files/upload
|
||||
</code>
|
||||
; the returned URL is stored as the program thumbnail.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={closeEdit}
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-brand-500 hover:bg-brand-600"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
onClick={() => void handleSaveEdit()}
|
||||
>
|
||||
{savingEdit ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{deletingProgram && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
|
||||
<h2 className="text-lg font-bold text-grayScale-700">Delete program</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !deleting && setDeletingProgram(null)}
|
||||
disabled={deleting}
|
||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-6">
|
||||
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
|
||||
<Trash2 className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<p className="text-center text-sm leading-relaxed text-grayScale-600">
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-grayScale-700">{deletingProgram.name}</span>? This action cannot be
|
||||
undone. Courses under this program may be affected depending on your backend.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDeletingProgram(null)}
|
||||
disabled={deleting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full bg-red-500 shadow-sm transition-all hover:bg-red-600 hover:shadow-md sm:w-auto"
|
||||
disabled={deleting}
|
||||
onClick={() => void handleConfirmDelete()}
|
||||
>
|
||||
{deleting ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Video,
|
||||
|
|
@ -7,42 +7,39 @@ import {
|
|||
Layers,
|
||||
Edit2,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
deleteTopLevelModuleLesson,
|
||||
getModuleLessons,
|
||||
getTopLevelCourseModules,
|
||||
updateTopLevelModuleLesson,
|
||||
} from "../../api/courses.api";
|
||||
import type { TopLevelModuleLessonItem } from "../../types/course.types";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { LessonMediaUploadField } from "./components/LessonMediaUploadField";
|
||||
import { VideoCard } from "./components/VideoCard";
|
||||
|
||||
const MOCK_VIDEOS = [
|
||||
{
|
||||
id: "v1",
|
||||
title: "1.1 Introduction to Formal Greetings",
|
||||
duration: "08:45",
|
||||
status: "Draft",
|
||||
thumbnailGradient: "from-[#CBD5E1] to-[#94A3B8]",
|
||||
},
|
||||
{
|
||||
id: "v2",
|
||||
title: "1.2 Understanding Email Structure",
|
||||
duration: "08:45",
|
||||
status: "Published",
|
||||
thumbnailGradient: "from-[#DBEAFE] to-[#93C5FD]",
|
||||
},
|
||||
{
|
||||
id: "v3",
|
||||
title: "1.3 Common Business Idioms",
|
||||
duration: "08:45",
|
||||
status: "Published",
|
||||
thumbnailGradient: "from-[#FEF3C7] to-[#FCD34D]",
|
||||
},
|
||||
{
|
||||
id: "v4",
|
||||
title: "1.4 Video Conference Etiquette",
|
||||
duration: "08:45",
|
||||
status: "Published",
|
||||
thumbnailGradient: "from-[#FCE7F3] to-[#F9A8D4]",
|
||||
},
|
||||
];
|
||||
const LESSON_THUMB_GRADIENTS = [
|
||||
"from-[#CBD5E1] to-[#94A3B8]",
|
||||
"from-[#DBEAFE] to-[#93C5FD]",
|
||||
"from-[#FEF3C7] to-[#FCD34D]",
|
||||
"from-[#FCE7F3] to-[#F9A8D4]",
|
||||
] as const;
|
||||
|
||||
const MOCK_PRACTICES = [
|
||||
{
|
||||
|
|
@ -75,8 +72,15 @@ const MOCK_PRACTICES = [
|
|||
},
|
||||
];
|
||||
|
||||
type ModuleDetailState = {
|
||||
moduleName?: string;
|
||||
moduleDescription?: string;
|
||||
};
|
||||
|
||||
export function ModuleDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const navState = location.state as ModuleDetailState | null;
|
||||
const { level, courseId, moduleId } = useParams<{
|
||||
level: string;
|
||||
courseId: string;
|
||||
|
|
@ -84,14 +88,211 @@ export function ModuleDetailPage() {
|
|||
}>();
|
||||
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
|
||||
const [activeFilter, setActiveFilter] = useState("Draft");
|
||||
const [videos] = useState(MOCK_VIDEOS);
|
||||
const [lessons, setLessons] = useState<TopLevelModuleLessonItem[]>([]);
|
||||
const [lessonsLoading, setLessonsLoading] = useState(true);
|
||||
const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null);
|
||||
const [editingLesson, setEditingLesson] =
|
||||
useState<TopLevelModuleLessonItem | null>(null);
|
||||
const [editLessonTitle, setEditLessonTitle] = useState("");
|
||||
const [editLessonVideoUrl, setEditLessonVideoUrl] = useState("");
|
||||
const [editLessonThumbnail, setEditLessonThumbnail] = useState("");
|
||||
const [editLessonDescription, setEditLessonDescription] = useState("");
|
||||
const [savingLessonEdit, setSavingLessonEdit] = useState(false);
|
||||
const [thumbUploadBusy, setThumbUploadBusy] = useState(false);
|
||||
const [videoUploadBusy, setVideoUploadBusy] = useState(false);
|
||||
const lessonMediaUploadBusy = thumbUploadBusy || videoUploadBusy;
|
||||
const [deletingLesson, setDeletingLesson] =
|
||||
useState<TopLevelModuleLessonItem | null>(null);
|
||||
const [deletingLessonInFlight, setDeletingLessonInFlight] = useState(false);
|
||||
const [practices] = useState(MOCK_PRACTICES);
|
||||
const [loadedModuleName, setLoadedModuleName] = useState<string | null>(null);
|
||||
const [loadedModuleDescription, setLoadedModuleDescription] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [moduleListResolved, setModuleListResolved] = useState(
|
||||
Boolean(navState?.moduleName?.trim()),
|
||||
);
|
||||
|
||||
const moduleTitle =
|
||||
const moduleTitleFallback =
|
||||
moduleId
|
||||
?.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ") || "Business English Fundamentals";
|
||||
.join(" ") || "Module";
|
||||
|
||||
const displayModuleName =
|
||||
navState?.moduleName?.trim() ||
|
||||
loadedModuleName ||
|
||||
moduleTitleFallback;
|
||||
|
||||
const hasNavName = Boolean(navState?.moduleName?.trim());
|
||||
|
||||
const displayModuleDescription = (() => {
|
||||
if (hasNavName) {
|
||||
return navState?.moduleDescription?.trim() || "—";
|
||||
}
|
||||
if (!moduleListResolved) {
|
||||
return "Loading…";
|
||||
}
|
||||
if (loadedModuleDescription !== null) {
|
||||
return loadedModuleDescription.trim() || "—";
|
||||
}
|
||||
return "—";
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (navState?.moduleName?.trim()) {
|
||||
return;
|
||||
}
|
||||
const id = Number(moduleId);
|
||||
const cid = Number(courseId);
|
||||
if (!Number.isFinite(id) || id < 1 || !Number.isFinite(cid) || cid < 1) {
|
||||
setModuleListResolved(true);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await getTopLevelCourseModules(cid, { limit: 100, offset: 0 });
|
||||
if (cancelled) return;
|
||||
const list = res.data?.data?.modules;
|
||||
if (Array.isArray(list)) {
|
||||
const m = list.find((mod) => mod.id === id);
|
||||
if (m) {
|
||||
setLoadedModuleName(m.name);
|
||||
setLoadedModuleDescription(m.description ?? "");
|
||||
} else {
|
||||
setLoadedModuleName(null);
|
||||
setLoadedModuleDescription("");
|
||||
}
|
||||
} else {
|
||||
setLoadedModuleName(null);
|
||||
setLoadedModuleDescription(null);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setLoadedModuleName(null);
|
||||
setLoadedModuleDescription(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setModuleListResolved(true);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [navState?.moduleName, courseId, moduleId]);
|
||||
|
||||
const loadModuleLessons = useCallback(
|
||||
async (options?: { showPageLoading?: boolean }) => {
|
||||
const showPageLoading = options?.showPageLoading ?? true;
|
||||
const mid = Number(moduleId);
|
||||
if (!Number.isFinite(mid) || mid < 1) {
|
||||
setLessons([]);
|
||||
setLessonsLoadError(null);
|
||||
setLessonsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (showPageLoading) {
|
||||
setLessonsLoading(true);
|
||||
setLessonsLoadError(null);
|
||||
}
|
||||
try {
|
||||
const res = await getModuleLessons(mid, { limit: 100, offset: 0 });
|
||||
const list = res.data?.data?.lessons;
|
||||
if (Array.isArray(list)) {
|
||||
setLessons(
|
||||
[...list].sort(
|
||||
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setLessons([]);
|
||||
}
|
||||
if (showPageLoading) {
|
||||
setLessonsLoadError(null);
|
||||
}
|
||||
} catch {
|
||||
if (showPageLoading) {
|
||||
setLessons([]);
|
||||
setLessonsLoadError("Failed to load lessons. Please try again.");
|
||||
} else {
|
||||
toast.error("Failed to refresh lessons");
|
||||
}
|
||||
} finally {
|
||||
if (showPageLoading) {
|
||||
setLessonsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[moduleId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadModuleLessons({ showPageLoading: true });
|
||||
}, [loadModuleLessons]);
|
||||
|
||||
const openEditLesson = (lesson: TopLevelModuleLessonItem) => {
|
||||
setEditingLesson(lesson);
|
||||
setEditLessonTitle(lesson.title ?? "");
|
||||
setEditLessonVideoUrl(lesson.video_url ?? "");
|
||||
setEditLessonThumbnail(lesson.thumbnail ?? "");
|
||||
setEditLessonDescription(lesson.description ?? "");
|
||||
};
|
||||
|
||||
const closeEditLesson = () => {
|
||||
if (savingLessonEdit || lessonMediaUploadBusy) return;
|
||||
setEditingLesson(null);
|
||||
};
|
||||
|
||||
const handleSaveLessonEdit = async () => {
|
||||
if (!editingLesson) return;
|
||||
const title = editLessonTitle.trim();
|
||||
if (!title) {
|
||||
toast.error("Title is required");
|
||||
return;
|
||||
}
|
||||
setSavingLessonEdit(true);
|
||||
try {
|
||||
await updateTopLevelModuleLesson(editingLesson.id, {
|
||||
title,
|
||||
video_url: editLessonVideoUrl.trim(),
|
||||
thumbnail: editLessonThumbnail.trim(),
|
||||
description: editLessonDescription.trim(),
|
||||
});
|
||||
toast.success("Lesson updated");
|
||||
setEditingLesson(null);
|
||||
await loadModuleLessons({ showPageLoading: false });
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to update lesson";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSavingLessonEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDeleteLesson = async () => {
|
||||
if (!deletingLesson) return;
|
||||
setDeletingLessonInFlight(true);
|
||||
try {
|
||||
await deleteTopLevelModuleLesson(deletingLesson.id);
|
||||
toast.success("Lesson deleted");
|
||||
setDeletingLesson(null);
|
||||
await loadModuleLessons({ showPageLoading: false });
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to delete lesson";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setDeletingLessonInFlight(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-10 pt-10 pb-20 animate-in fade-in duration-500">
|
||||
|
|
@ -110,12 +311,10 @@ export function ModuleDetailPage() {
|
|||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-6">
|
||||
<div className="">
|
||||
<h1 className="text-2xl font-medium text-grayScale-900 tracking-tight">
|
||||
Module 3: {moduleTitle}
|
||||
{displayModuleName}
|
||||
</h1>
|
||||
<p className="text-grayScale-500 text-[14px] max-w-2xl">
|
||||
This module covers essential vocabulary and phrases used in modern
|
||||
business environments, including email etiquette and meeting
|
||||
protocols.
|
||||
{displayModuleDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -142,7 +341,7 @@ export function ModuleDetailPage() {
|
|||
<div className="h-4 w-4 flex items-center justify-center">
|
||||
<span className="text-xl leading-none font-light">+</span>
|
||||
</div>
|
||||
Add Video
|
||||
Add Lesson
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -159,7 +358,7 @@ export function ModuleDetailPage() {
|
|||
: "text-grayScale-400 hover:text-grayScale-600",
|
||||
)}
|
||||
>
|
||||
Video
|
||||
Lesson
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("practice")}
|
||||
|
|
@ -178,14 +377,27 @@ export function ModuleDetailPage() {
|
|||
{/* Content */}
|
||||
<div className="mt-8">
|
||||
{activeTab === "video" ? (
|
||||
videos.length > 0 ? (
|
||||
lessonsLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-grayScale-500 text-[15px] font-medium">
|
||||
Loading lessons…
|
||||
</div>
|
||||
) : lessonsLoadError ? (
|
||||
<div className="rounded-2xl border border-amber-100 bg-amber-50/80 px-6 py-8 text-center text-sm text-amber-900 max-w-lg mx-auto">
|
||||
{lessonsLoadError}
|
||||
</div>
|
||||
) : lessons.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{videos.map((video) => (
|
||||
{lessons.map((lesson, i) => (
|
||||
<VideoCard
|
||||
key={video.id}
|
||||
{...(video as any)}
|
||||
onEdit={() => console.log("Edit", video.id)}
|
||||
onPublish={() => console.log("Publish", video.id)}
|
||||
key={lesson.id}
|
||||
id={lesson.id}
|
||||
title={lesson.title}
|
||||
videoUrl={lesson.video_url}
|
||||
hoverModuleActions
|
||||
thumbnailUrl={resolveThumbnailForPreview(lesson.thumbnail)}
|
||||
thumbnailGradient={LESSON_THUMB_GRADIENTS[i % LESSON_THUMB_GRADIENTS.length]}
|
||||
onEdit={() => openEditLesson(lesson)}
|
||||
onDelete={() => setDeletingLesson(lesson)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -197,11 +409,11 @@ export function ModuleDetailPage() {
|
|||
</div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
|
||||
No videos added to this module yet
|
||||
No lessons in this module yet
|
||||
</h2>
|
||||
<p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed">
|
||||
Videos are a great way to engage students. Start building your
|
||||
module by adding your first video lesson now.
|
||||
Lessons are a great way to engage students. Add your first
|
||||
lesson to get started.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -213,7 +425,7 @@ export function ModuleDetailPage() {
|
|||
}
|
||||
>
|
||||
<Video className="h-5 w-5" />
|
||||
Add Video
|
||||
Add Lesson
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -251,6 +463,149 @@ export function ModuleDetailPage() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={editingLesson !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && (savingLessonEdit || lessonMediaUploadBusy)) return;
|
||||
if (!open) closeEditLesson();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit lesson</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update details. Video and thumbnail files use{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||
POST /files/upload
|
||||
</code>
|
||||
; the form is saved with{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||
PUT /lessons/:id
|
||||
</code>
|
||||
.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
className="text-sm font-medium text-grayScale-700"
|
||||
htmlFor="edit-lesson-title"
|
||||
>
|
||||
Title
|
||||
</label>
|
||||
<Input
|
||||
id="edit-lesson-title"
|
||||
value={editLessonTitle}
|
||||
onChange={(e) => setEditLessonTitle(e.target.value)}
|
||||
disabled={savingLessonEdit}
|
||||
/>
|
||||
</div>
|
||||
<LessonMediaUploadField
|
||||
kind="video"
|
||||
value={editLessonVideoUrl}
|
||||
onChange={setEditLessonVideoUrl}
|
||||
disabled={savingLessonEdit}
|
||||
onUploadBusyChange={setVideoUploadBusy}
|
||||
/>
|
||||
<LessonMediaUploadField
|
||||
kind="thumbnail"
|
||||
value={editLessonThumbnail}
|
||||
onChange={setEditLessonThumbnail}
|
||||
disabled={savingLessonEdit}
|
||||
onUploadBusyChange={setThumbUploadBusy}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
className="text-sm font-medium text-grayScale-700"
|
||||
htmlFor="edit-lesson-desc"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
id="edit-lesson-desc"
|
||||
value={editLessonDescription}
|
||||
onChange={(e) => setEditLessonDescription(e.target.value)}
|
||||
rows={4}
|
||||
disabled={savingLessonEdit}
|
||||
className="min-h-[100px] resize-y"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={closeEditLesson}
|
||||
disabled={savingLessonEdit || lessonMediaUploadBusy}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleSaveLessonEdit()}
|
||||
disabled={savingLessonEdit || lessonMediaUploadBusy}
|
||||
>
|
||||
{savingLessonEdit ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{deletingLesson && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
|
||||
<h2 className="text-lg font-bold text-grayScale-700">
|
||||
Delete lesson
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
!deletingLessonInFlight && setDeletingLesson(null)
|
||||
}
|
||||
disabled={deletingLessonInFlight}
|
||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-6">
|
||||
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
|
||||
<Trash2 className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<p className="text-center text-sm leading-relaxed text-grayScale-600">
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-grayScale-700">
|
||||
{deletingLesson.title}
|
||||
</span>
|
||||
? This cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDeletingLesson(null)}
|
||||
disabled={deletingLessonInFlight}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full bg-red-500 shadow-sm transition-all hover:bg-red-600 hover:shadow-md sm:w-auto"
|
||||
disabled={deletingLessonInFlight}
|
||||
onClick={() => void handleConfirmDeleteLesson()}
|
||||
>
|
||||
{deletingLessonInFlight ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -58,7 +58,13 @@ const typeColors: Record<QuestionType, string> = {
|
|||
}
|
||||
|
||||
export function PracticeQuestionsPage() {
|
||||
const { categoryId, courseId, subModuleId, practiceId } = useParams()
|
||||
const { categoryId, courseId, subModuleId, levelId, practiceId } = useParams<{
|
||||
categoryId: string
|
||||
courseId: string
|
||||
subModuleId?: string
|
||||
levelId?: string
|
||||
practiceId?: string
|
||||
}>()
|
||||
const location = useLocation()
|
||||
|
||||
const [questions, setQuestions] = useState<PracticeQuestion[]>([])
|
||||
|
|
@ -102,11 +108,14 @@ export function PracticeQuestionsPage() {
|
|||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
|
||||
const backLink = useMemo(() => {
|
||||
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/")) {
|
||||
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/level/") && levelId) {
|
||||
return "/content/human-language"
|
||||
}
|
||||
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/") && subModuleId) {
|
||||
return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}`
|
||||
}
|
||||
return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`
|
||||
}, [location.pathname, categoryId, courseId, subModuleId])
|
||||
}, [location.pathname, categoryId, courseId, subModuleId, levelId])
|
||||
|
||||
const buildDefaultOptions = (type: QuestionType, sampleAnswerText?: string): DraftOption[] => {
|
||||
if (type === "TRUE_FALSE") {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,5 +1,5 @@
|
|||
import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, Image as ImageIcon, Loader2, Mic, Plus, Trash2, Upload } from "lucide-react"
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, Image as ImageIcon, Mic, Plus, Trash2, Upload } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Input } from "../../components/ui/input"
|
||||
|
|
@ -1926,7 +1926,7 @@ export function SpeakingPage() {
|
|||
className="gap-1.5"
|
||||
>
|
||||
{uploadingIntroVideo ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<SpinnerIcon className="h-4 w-4" alt="" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
|
|
|
|||
143
src/pages/content-management/SubCategoryCoursesPage.tsx
Normal file
143
src/pages/content-management/SubCategoryCoursesPage.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
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" />
|
||||
</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 {
|
||||
const subCoursesRes = await getSubModulesByCourse(Number(courseId))
|
||||
const foundSubCourse = subCoursesRes.data.data.sub_courses?.find(
|
||||
(sc) => sc.id === Number(subModuleId)
|
||||
)
|
||||
const list = subCoursesRes.data?.data?.sub_courses
|
||||
const foundSubCourse = Array.isArray(list)
|
||||
? list.find((sc) => sc.id === Number(subModuleId))
|
||||
: undefined
|
||||
setSubCourse(foundSubCourse ?? null)
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch course data:", err)
|
||||
|
|
@ -123,7 +124,9 @@ export function SubModuleContentPage() {
|
|||
setPracticesLoading(true)
|
||||
try {
|
||||
const res = await getQuestionSetsByOwner("SUB_MODULE", Number(subModuleId))
|
||||
setPractices(res.data.data ?? [])
|
||||
const raw = res.data?.data
|
||||
const list = Array.isArray(raw) ? raw : (raw as { question_sets?: QuestionSet[] })?.question_sets ?? []
|
||||
setPractices(Array.isArray(list) ? list : [])
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch practices:", err)
|
||||
} finally {
|
||||
|
|
@ -136,7 +139,8 @@ export function SubModuleContentPage() {
|
|||
setVideosLoading(true)
|
||||
try {
|
||||
const res = await getVideosBySubModule(Number(subModuleId))
|
||||
setVideos(res.data.data.videos ?? [])
|
||||
const vids = res.data?.data?.videos ?? []
|
||||
setVideos(Array.isArray(vids) ? vids : [])
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch videos:", err)
|
||||
} finally {
|
||||
|
|
@ -154,7 +158,7 @@ export function SubModuleContentPage() {
|
|||
limit: ratingsPageSize,
|
||||
offset,
|
||||
})
|
||||
setRatings(res.data.data ?? [])
|
||||
setRatings(res.data?.data ?? [])
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch ratings:", err)
|
||||
} finally {
|
||||
|
|
@ -405,8 +409,8 @@ export function SubModuleContentPage() {
|
|||
const idMatch = video.video_url?.match(/(\d{5,})/)
|
||||
const vimeoId = idMatch?.[1] ?? "76979871" // fallback to Big Buck Bunny
|
||||
const res = await getVimeoSample(vimeoId)
|
||||
setPreviewIframe(res.data.data.iframe)
|
||||
setPreviewVideo(res.data.data.video)
|
||||
setPreviewIframe(res.data?.data?.iframe ?? "")
|
||||
setPreviewVideo(res.data?.data?.video ?? null)
|
||||
} catch {
|
||||
setPreviewIframe("")
|
||||
} finally {
|
||||
|
|
@ -414,7 +418,7 @@ export function SubModuleContentPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const filteredPractices = practices.filter((practice) => {
|
||||
const filteredPractices = (Array.isArray(practices) ? practices : []).filter((practice) => {
|
||||
if (statusFilter === "all") return true
|
||||
if (statusFilter === "published") return practice.status === "PUBLISHED"
|
||||
if (statusFilter === "draft") return practice.status === "DRAFT"
|
||||
|
|
@ -440,6 +444,19 @@ export function SubModuleContentPage() {
|
|||
)
|
||||
}
|
||||
|
||||
if (!subCourse) {
|
||||
return (
|
||||
<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 (
|
||||
<div className="space-y-6">
|
||||
{/* 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-1.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>
|
||||
{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>
|
||||
|
|
@ -599,11 +616,13 @@ export function SubModuleContentPage() {
|
|||
|
||||
<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">
|
||||
{new Date(practice.created_at).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
{practice.created_at
|
||||
? new Date(practice.created_at).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
: "—"}
|
||||
</span>
|
||||
<div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,4 +1,4 @@
|
|||
import { X } from "lucide-react";
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -9,51 +9,137 @@ import {
|
|||
DialogClose,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Select } from "../../../components/ui/select";
|
||||
import uploadIcon from "../../../assets/icons/upload.png";
|
||||
import { Textarea } from "../../../components/ui/textarea";
|
||||
import { toast } from "sonner";
|
||||
import { createTopLevelCourseModule } from "../../../api/courses.api";
|
||||
import { ModuleIconUploadField } from "./ModuleIconUploadField";
|
||||
|
||||
interface AddModuleModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
courseId: number;
|
||||
onCreated?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function AddModuleModal({ isOpen, onClose }: AddModuleModalProps) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl gap-0 border-none p-0 overflow-hidden rounded-[16px] shadow-2xl">
|
||||
<DialogHeader className="p-8 pb-4 relative">
|
||||
<DialogTitle className="text-2xl font-bold text-grayScale-700">
|
||||
Add New Module
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-400">
|
||||
Create a module to organize videos and practices.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
export function AddModuleModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
courseId,
|
||||
onCreated,
|
||||
}: AddModuleModalProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [icon, setIcon] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [iconUploadBusy, setIconUploadBusy] = useState(false);
|
||||
|
||||
{/* Gradient Divider */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-0 flex items-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="w-full border-t border-grayScale-100" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setIcon("");
|
||||
setSubmitting(false);
|
||||
setIconUploadBusy(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const resetAndClose = () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setIcon("");
|
||||
setIconUploadBusy(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open && (submitting || iconUploadBusy)) return;
|
||||
if (!open) {
|
||||
resetAndClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
toast.error("Module name is required");
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(courseId) || courseId < 1) {
|
||||
toast.error("Invalid course");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createTopLevelCourseModule(courseId, {
|
||||
name: trimmedName,
|
||||
description: description.trim(),
|
||||
icon: icon.trim(),
|
||||
});
|
||||
toast.success("Module created");
|
||||
if (onCreated) {
|
||||
await onCreated();
|
||||
}
|
||||
resetAndClose();
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
const msg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to create module";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-2xl flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0 shadow-2xl">
|
||||
<div className="flex-shrink-0">
|
||||
<DialogHeader className="relative p-8 pb-4">
|
||||
<DialogTitle className="text-2xl font-bold text-grayScale-700">
|
||||
Add New Module
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-400">
|
||||
Create a module with{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||
POST /courses/:courseId/modules
|
||||
</code>
|
||||
.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative">
|
||||
<div
|
||||
className="h-[0.5px] w-full opacity-20"
|
||||
style={{ background: "gray" }}
|
||||
/>
|
||||
className="absolute inset-0 flex items-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="w-full border-t border-grayScale-100" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<div
|
||||
className="h-[0.5px] w-full opacity-20"
|
||||
style={{ background: "gray" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="space-y-6 p-8 pt-4">
|
||||
<form
|
||||
className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain p-8 pt-4"
|
||||
onSubmit={(e) => void handleSubmit(e)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] font-medium text-grayScale-700">
|
||||
Module Title
|
||||
Module title
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Daily Introductions"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Greetings & Introductions"
|
||||
className="h-12 rounded-xl"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -61,63 +147,40 @@ export function AddModuleModal({ isOpen, onClose }: AddModuleModalProps) {
|
|||
<label className="text-[15px] font-medium text-grayScale-700">
|
||||
Description
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Short description of this module"
|
||||
className="h-12 rounded-xl"
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Learn to introduce yourself and talk about your life."
|
||||
className="min-h-[88px] resize-y rounded-xl"
|
||||
disabled={submitting}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] font-medium text-grayScale-700">
|
||||
Module Order
|
||||
</label>
|
||||
<Select className="h-12 rounded-xl">
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] font-medium text-grayScale-700">
|
||||
Icon
|
||||
</label>
|
||||
<div className="relative group cursor-pointer">
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-[#9E289133] bg-white p-10 transition-all">
|
||||
<div className="mb-4">
|
||||
<img
|
||||
src={uploadIcon}
|
||||
alt="Upload icon"
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
<span className="font-bold text-[#9E2891]">
|
||||
Click to upload
|
||||
</span>{" "}
|
||||
<span className="text-grayScale-500">or drag and drop</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-medium text-grayScale-400 uppercase tracking-wider">
|
||||
JPG, PNG (MAX 1 MB)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ModuleIconUploadField
|
||||
value={icon}
|
||||
onChange={setIcon}
|
||||
disabled={submitting}
|
||||
onUploadBusyChange={setIconUploadBusy}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12 min-w-[120px] rounded-xl border-grayScale-200 font-semibold"
|
||||
disabled={submitting || iconUploadBusy}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-12 min-w-[160px] rounded-xl bg-brand-500 font-semibold hover:bg-brand-600 text-white shadow-lg shadow-brand-500/20"
|
||||
className="h-12 min-w-[160px] rounded-xl bg-brand-500 font-semibold text-white shadow-lg shadow-brand-500/20 hover:bg-brand-600"
|
||||
disabled={submitting || iconUploadBusy}
|
||||
>
|
||||
Create Module
|
||||
{submitting ? "Creating…" : "Create module"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
481
src/pages/content-management/components/CreatePracticeWizard.tsx
Normal file
481
src/pages/content-management/components/CreatePracticeWizard.tsx
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Check, ChevronLeft, ChevronRight, ListOrdered, Plus, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "../../../components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../../components/ui/card"
|
||||
import { Input } from "../../../components/ui/input"
|
||||
import { Textarea } from "../../../components/ui/textarea"
|
||||
import {
|
||||
addQuestionToSet,
|
||||
createParentLinkedPractice,
|
||||
createQuestion,
|
||||
createQuestionSet,
|
||||
} from "../../../api/courses.api"
|
||||
import type { CreateQuestionRequest, PracticeParentKind } from "../../../types/course.types"
|
||||
import { cn } from "../../../lib/utils"
|
||||
import { SpinnerIcon } from "../../../components/ui/spinner-icon"
|
||||
|
||||
export type CreatePracticeWizardParent = {
|
||||
kind: PracticeParentKind
|
||||
id: number
|
||||
} | null
|
||||
|
||||
const STEPS = [
|
||||
{ n: 1, label: "Question set" },
|
||||
{ n: 2, label: "Questions" },
|
||||
{ n: 3, label: "Attach" },
|
||||
{ n: 4, label: "Practice" },
|
||||
] as const
|
||||
|
||||
type QuestionDraft = {
|
||||
question_text: string
|
||||
voice_prompt: string
|
||||
sample_answer_voice_prompt: string
|
||||
audio_correct_answer_text: string
|
||||
}
|
||||
|
||||
const emptyQuestion = (): QuestionDraft => ({
|
||||
question_text: "",
|
||||
voice_prompt: "",
|
||||
sample_answer_voice_prompt: "",
|
||||
audio_correct_answer_text: "",
|
||||
})
|
||||
|
||||
type Props = {
|
||||
parent: CreatePracticeWizardParent
|
||||
onCreated?: () => void
|
||||
}
|
||||
|
||||
export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
||||
const [step, setStep] = useState(1)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const [setTitle, setSetTitle] = useState("")
|
||||
const [questionSetId, setQuestionSetId] = useState<number | null>(null)
|
||||
|
||||
const [questionRows, setQuestionRows] = useState<QuestionDraft[]>([emptyQuestion()])
|
||||
const [createdQuestionIds, setCreatedQuestionIds] = useState<number[]>([])
|
||||
|
||||
const [practiceTitle, setPracticeTitle] = useState("")
|
||||
const [storyDescription, setStoryDescription] = useState("")
|
||||
const [storyImage, setStoryImage] = useState("")
|
||||
const [quickTips, setQuickTips] = useState("")
|
||||
|
||||
const canUseWizard = parent != null
|
||||
|
||||
useEffect(() => {
|
||||
if (step === 4 && setTitle.trim() && !practiceTitle.trim()) {
|
||||
setPracticeTitle(setTitle.trim())
|
||||
}
|
||||
}, [step, setTitle, practiceTitle])
|
||||
|
||||
const resetAll = useCallback(() => {
|
||||
setStep(1)
|
||||
setSetTitle("")
|
||||
setQuestionSetId(null)
|
||||
setQuestionRows([emptyQuestion()])
|
||||
setCreatedQuestionIds([])
|
||||
setPracticeTitle("")
|
||||
setStoryDescription("")
|
||||
setStoryImage("")
|
||||
setQuickTips("")
|
||||
}, [])
|
||||
|
||||
const handleStep1 = async () => {
|
||||
if (!setTitle.trim()) {
|
||||
toast.error("Enter a title for the question set")
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await createQuestionSet({
|
||||
title: setTitle.trim(),
|
||||
set_type: "PRACTICE",
|
||||
})
|
||||
const id = res.data?.data?.id
|
||||
if (id == null) {
|
||||
throw new Error("No question set id in response")
|
||||
}
|
||||
setQuestionSetId(id)
|
||||
toast.success("Question set created")
|
||||
setStep(2)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } }; message?: string }
|
||||
toast.error(err.response?.data?.message || err.message || "Failed to create question set")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStep2 = async () => {
|
||||
for (let i = 0; i < questionRows.length; i++) {
|
||||
const r = questionRows[i]
|
||||
if (!r.question_text.trim()) {
|
||||
toast.error(`Question ${i + 1}: enter question text`)
|
||||
return
|
||||
}
|
||||
if (!r.voice_prompt.trim() || !r.sample_answer_voice_prompt.trim()) {
|
||||
toast.error(`Question ${i + 1}: enter voice prompt URLs`)
|
||||
return
|
||||
}
|
||||
if (!r.audio_correct_answer_text.trim()) {
|
||||
toast.error(`Question ${i + 1}: enter the correct answer text`)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (questionSetId == null) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const ids: number[] = []
|
||||
for (const r of questionRows) {
|
||||
const body: CreateQuestionRequest = {
|
||||
question_text: r.question_text.trim(),
|
||||
question_type: "AUDIO",
|
||||
voice_prompt: r.voice_prompt.trim(),
|
||||
sample_answer_voice_prompt: r.sample_answer_voice_prompt.trim(),
|
||||
audio_correct_answer_text: r.audio_correct_answer_text.trim(),
|
||||
}
|
||||
const res = await createQuestion(body)
|
||||
const qid = res.data?.data?.id
|
||||
if (qid == null) {
|
||||
throw new Error("A question was created but no id was returned")
|
||||
}
|
||||
ids.push(qid)
|
||||
}
|
||||
setCreatedQuestionIds(ids)
|
||||
toast.success(`Created ${ids.length} question(s)`)
|
||||
setStep(3)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } }; message?: string }
|
||||
toast.error(err.response?.data?.message || err.message || "Failed to create questions")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStep3 = async () => {
|
||||
if (questionSetId == null || createdQuestionIds.length === 0) return
|
||||
setSaving(true)
|
||||
try {
|
||||
for (let i = 0; i < createdQuestionIds.length; i++) {
|
||||
await addQuestionToSet(questionSetId, {
|
||||
question_id: createdQuestionIds[i],
|
||||
display_order: i + 1,
|
||||
})
|
||||
}
|
||||
toast.success("Questions linked to the set")
|
||||
setStep(4)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } }; message?: string }
|
||||
toast.error(err.response?.data?.message || err.message || "Failed to attach questions")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStep4 = async () => {
|
||||
if (!parent || questionSetId == null) return
|
||||
if (!practiceTitle.trim() || !storyDescription.trim() || !storyImage.trim()) {
|
||||
toast.error("Title, story description, and story image are required")
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await createParentLinkedPractice({
|
||||
parent_kind: parent.kind,
|
||||
parent_id: parent.id,
|
||||
title: practiceTitle.trim(),
|
||||
story_description: storyDescription.trim(),
|
||||
story_image: storyImage.trim(),
|
||||
question_set_id: questionSetId,
|
||||
quick_tips: quickTips.trim(),
|
||||
})
|
||||
toast.success("Practice created successfully")
|
||||
resetAll()
|
||||
onCreated?.()
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } }; message?: string }
|
||||
toast.error(err.response?.data?.message || err.message || "Failed to create practice")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-brand-200/60 shadow-soft">
|
||||
<CardHeader className="border-b border-grayScale-200 pb-4">
|
||||
<CardTitle className="text-base font-semibold text-grayScale-800">Create a new practice</CardTitle>
|
||||
<p className="text-sm font-normal text-grayScale-500">
|
||||
Four steps: create a question set, add audio questions, attach them, then set the practice
|
||||
story. Select the course, module, or lesson above first.
|
||||
</p>
|
||||
<ol className="mt-4 flex flex-wrap gap-2">
|
||||
{STEPS.map((s) => {
|
||||
const done = step > s.n
|
||||
const active = step === s.n
|
||||
return (
|
||||
<li
|
||||
key={s.n}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-bold uppercase tracking-wider",
|
||||
done && "border-mint-500/40 bg-mint-50 text-mint-800",
|
||||
active && !done && "border-brand-500 bg-brand-500 text-white",
|
||||
!active && !done && "border-grayScale-200 bg-white text-grayScale-500",
|
||||
)}
|
||||
>
|
||||
{done ? <Check className="h-3.5 w-3.5" /> : <span className="font-mono tabular-nums">{s.n}</span>}
|
||||
{s.label}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-5">
|
||||
{!canUseWizard && (
|
||||
<p className="rounded-xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm text-amber-900">
|
||||
Choose a program, course, and the target (course / module / lesson) in the "Look up
|
||||
practice" section, then return here. The practice is created for the same selection
|
||||
(course id, module id, or lesson id).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{canUseWizard && step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
Question set title
|
||||
</p>
|
||||
<Input
|
||||
value={setTitle}
|
||||
onChange={(e) => setSetTitle(e.target.value)}
|
||||
placeholder='e.g. "Course-A1 practice"'
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
This calls <span className="font-mono">POST /question-sets</span> with{" "}
|
||||
<span className="font-mono">set_type: PRACTICE</span>.
|
||||
</p>
|
||||
<Button type="button" onClick={handleStep1} disabled={saving}>
|
||||
{saving ? <SpinnerIcon className="h-4 w-4" /> : null}
|
||||
Create question set & continue
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canUseWizard && step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-grayScale-600">
|
||||
Set id <span className="font-mono font-medium text-grayScale-800">#{questionSetId}</span> — add
|
||||
one or more <strong>AUDIO</strong> questions. Each is created via{" "}
|
||||
<span className="font-mono">POST /questions</span>.
|
||||
</p>
|
||||
{questionRows.map((row, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="space-y-3 rounded-2xl border border-grayScale-200 bg-grayScale-50/50 p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-grayScale-500">
|
||||
Question {idx + 1}
|
||||
</span>
|
||||
{questionRows.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-red-600 hover:text-red-700"
|
||||
onClick={() => setQuestionRows((rows) => rows.filter((_, i) => i !== idx))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-grayScale-500">Question text</p>
|
||||
<Textarea
|
||||
value={row.question_text}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setQuestionRows((rows) =>
|
||||
rows.map((r, i) => (i === idx ? { ...r, question_text: v } : r)),
|
||||
)
|
||||
}}
|
||||
rows={2}
|
||||
placeholder="Thank you for your help!"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-grayScale-500">Voice prompt (URL)</p>
|
||||
<Input
|
||||
value={row.voice_prompt}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setQuestionRows((rows) =>
|
||||
rows.map((r, i) => (i === idx ? { ...r, voice_prompt: v } : r)),
|
||||
)
|
||||
}}
|
||||
placeholder="https://…"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-grayScale-500">Sample answer voice (URL)</p>
|
||||
<Input
|
||||
value={row.sample_answer_voice_prompt}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setQuestionRows((rows) =>
|
||||
rows.map((r, i) => (i === idx ? { ...r, sample_answer_voice_prompt: v } : r)),
|
||||
)
|
||||
}}
|
||||
placeholder="https://…"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-grayScale-500">Correct answer text</p>
|
||||
<Textarea
|
||||
value={row.audio_correct_answer_text}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setQuestionRows((rows) =>
|
||||
rows.map((r, i) => (i === idx ? { ...r, audio_correct_answer_text: v } : r)),
|
||||
)
|
||||
}}
|
||||
rows={2}
|
||||
placeholder="You're welcome! Have a nice day!"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setQuestionRows((rows) => [...rows, emptyQuestion()])}
|
||||
disabled={saving}
|
||||
>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
Add another question
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => setStep(1)} disabled={saving}>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button type="button" onClick={handleStep2} disabled={saving}>
|
||||
{saving ? <SpinnerIcon className="h-4 w-4" /> : null}
|
||||
Create questions & continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canUseWizard && step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-grayScale-600">
|
||||
Link each question to the set with a display order using{" "}
|
||||
<span className="font-mono">POST /question-sets/{id}/questions</span>.
|
||||
</p>
|
||||
<ul className="space-y-2 rounded-xl border border-grayScale-200 bg-white p-3">
|
||||
{createdQuestionIds.map((qid, i) => (
|
||||
<li
|
||||
key={qid}
|
||||
className="flex items-center justify-between gap-2 text-sm text-grayScale-700"
|
||||
>
|
||||
<span className="font-mono">question #{qid}</span>
|
||||
<span className="flex items-center gap-1 text-xs text-grayScale-500">
|
||||
<ListOrdered className="h-3.5 w-3.5" />
|
||||
order {i + 1}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setStep(2)} disabled={saving}>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button type="button" onClick={handleStep3} disabled={saving}>
|
||||
{saving ? <SpinnerIcon className="h-4 w-4" /> : null}
|
||||
Attach to question set
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canUseWizard && step === 4 && parent && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-grayScale-600">
|
||||
Parent:{" "}
|
||||
<span className="font-mono text-xs">
|
||||
{parent.kind} #{parent.id}
|
||||
</span>{" "}
|
||||
· question set <span className="font-mono">#{questionSetId}</span> ·{" "}
|
||||
<span className="font-mono">POST /practices</span>
|
||||
</p>
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
Practice title
|
||||
</p>
|
||||
<Input
|
||||
value={practiceTitle}
|
||||
onChange={(e) => setPracticeTitle(e.target.value)}
|
||||
placeholder="Test title"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
Story description
|
||||
</p>
|
||||
<Textarea
|
||||
value={storyDescription}
|
||||
onChange={(e) => setStoryDescription(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Story for the learner…"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
Story image (URL)
|
||||
</p>
|
||||
<Input
|
||||
value={storyImage}
|
||||
onChange={(e) => setStoryImage(e.target.value)}
|
||||
placeholder="https://…"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
Quick tips
|
||||
</p>
|
||||
<Textarea
|
||||
value={quickTips}
|
||||
onChange={(e) => setQuickTips(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Comma-separated tips (optional)"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setStep(3)} disabled={saving}>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button type="button" onClick={handleStep4} disabled={saving}>
|
||||
{saving ? <SpinnerIcon className="h-4 w-4" /> : <ChevronRight className="mr-1.5 h-4 w-4" />}
|
||||
Create practice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
import { useCallback, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
||||
import { CloudUpload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { uploadImageFile, uploadVideoFile } from "../../../api/files.api";
|
||||
|
||||
const MAX_THUMB_BYTES = 5 * 1024 * 1024;
|
||||
const MAX_VIDEO_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
const THUMB_TYPES = new Set(["image/jpeg", "image/png"]);
|
||||
const VIDEO_TYPES_PREFIX = "video/";
|
||||
|
||||
function isAllowedThumb(file: File): boolean {
|
||||
if (THUMB_TYPES.has(file.type)) return true;
|
||||
const n = file.name.toLowerCase();
|
||||
return /\.(jpe?g|png)$/.test(n);
|
||||
}
|
||||
|
||||
function isAllowedVideoFile(file: File): boolean {
|
||||
if (file.type.startsWith(VIDEO_TYPES_PREFIX)) return true;
|
||||
const n = file.name.toLowerCase();
|
||||
return /\.(mp4|webm|mov|m4v|mkv)$/.test(n);
|
||||
}
|
||||
|
||||
export type LessonMediaUploadKind = "thumbnail" | "video";
|
||||
|
||||
export interface LessonMediaUploadFieldProps {
|
||||
kind: LessonMediaUploadKind;
|
||||
value: string;
|
||||
onChange: (url: string) => void;
|
||||
disabled?: boolean;
|
||||
onUploadBusyChange?: (busy: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LessonMediaUploadField({
|
||||
kind,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
onUploadBusyChange,
|
||||
className,
|
||||
}: LessonMediaUploadFieldProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
|
||||
const setBusy = useCallback(
|
||||
(next: boolean) => {
|
||||
setUploading(next);
|
||||
onUploadBusyChange?.(next);
|
||||
},
|
||||
[onUploadBusyChange],
|
||||
);
|
||||
|
||||
const processFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (disabled || uploading) return;
|
||||
|
||||
if (kind === "thumbnail") {
|
||||
if (!isAllowedThumb(file)) {
|
||||
toast.error("Please use a JPG or PNG image.");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_THUMB_BYTES) {
|
||||
toast.error("Image is too large", {
|
||||
description: "Maximum size is 5 MB.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await uploadImageFile(file);
|
||||
const url = res.data?.data?.url?.trim();
|
||||
if (!url) throw new Error("Upload did not return a file URL");
|
||||
onChange(url);
|
||||
toast.success("Thumbnail uploaded");
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to upload thumbnail";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAllowedVideoFile(file)) {
|
||||
toast.error("Please use a video file (e.g. MP4, WebM, MOV).");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_VIDEO_BYTES) {
|
||||
toast.error("Video is too large", {
|
||||
description: "Maximum size is 2 GB.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await uploadVideoFile(file);
|
||||
const url = res.data?.data?.url?.trim();
|
||||
if (!url) throw new Error("Upload did not return a file URL");
|
||||
onChange(url);
|
||||
toast.success("Video uploaded");
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to upload video";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[disabled, uploading, kind, onChange, setBusy],
|
||||
);
|
||||
|
||||
const handleFileInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (file) void processFile(file);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled && !uploading) setDragActive(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
if (disabled || uploading) return;
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) void processFile(file);
|
||||
};
|
||||
|
||||
const zoneDisabled = disabled || uploading;
|
||||
const isThumb = kind === "thumbnail";
|
||||
const label = isThumb ? "Thumbnail" : "Video";
|
||||
const hint = isThumb
|
||||
? "JPG, PNG (MAX 5 MB)"
|
||||
: "MP4, MOV, WebM (MAX 2 GB)";
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={
|
||||
isThumb
|
||||
? "image/jpeg,image/png,.jpg,.jpeg,.png"
|
||||
: "video/*,.mp4,.webm,.mov,.m4v,.mkv"
|
||||
}
|
||||
className="sr-only"
|
||||
onChange={handleFileInputChange}
|
||||
disabled={zoneDisabled}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={zoneDisabled}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-[#9E289133] bg-white p-10 text-center transition-colors",
|
||||
"hover:border-[#9E289180] hover:bg-grayScale-50/30",
|
||||
dragActive && "border-[#9E2891] bg-[#9E289108]",
|
||||
zoneDisabled && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
{uploading ? (
|
||||
<p className="text-sm font-medium text-grayScale-600">Uploading…</p>
|
||||
) : (
|
||||
<>
|
||||
<CloudUpload
|
||||
className="mb-4 h-10 w-10 text-[#9E2891]"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden
|
||||
/>
|
||||
<p className="text-sm">
|
||||
<span className="font-bold text-[#9E2891]">Click to upload</span>{" "}
|
||||
<span className="text-grayScale-500">or paste a URL below</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-medium uppercase tracking-wider text-grayScale-400">
|
||||
{hint}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="https://…"
|
||||
className="h-12 rounded-xl"
|
||||
disabled={disabled || uploading}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
import { useCallback, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
||||
import { CloudUpload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { uploadImageFile } from "../../../api/files.api";
|
||||
|
||||
const MAX_ICON_BYTES = 5 * 1024 * 1024;
|
||||
const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png"]);
|
||||
|
||||
function isAllowedImageFile(file: File): boolean {
|
||||
if (ALLOWED_IMAGE_TYPES.has(file.type)) return true;
|
||||
const name = file.name.toLowerCase();
|
||||
return /\.(jpe?g|png)$/.test(name);
|
||||
}
|
||||
|
||||
export interface ModuleIconUploadFieldProps {
|
||||
value: string;
|
||||
onChange: (url: string) => void;
|
||||
disabled?: boolean;
|
||||
/** Notifies parent so dialogs can block closing while an upload is in flight. */
|
||||
onUploadBusyChange?: (busy: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ModuleIconUploadField({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
onUploadBusyChange,
|
||||
className,
|
||||
}: ModuleIconUploadFieldProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
|
||||
const setBusy = useCallback(
|
||||
(next: boolean) => {
|
||||
setUploading(next);
|
||||
onUploadBusyChange?.(next);
|
||||
},
|
||||
[onUploadBusyChange],
|
||||
);
|
||||
|
||||
const processFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (disabled || uploading) return;
|
||||
if (!isAllowedImageFile(file)) {
|
||||
toast.error("Please use a JPG or PNG image.");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_ICON_BYTES) {
|
||||
toast.error("Image is too large", {
|
||||
description: "Maximum size is 5 MB.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await uploadImageFile(file);
|
||||
const url = res.data?.data?.url?.trim();
|
||||
if (!url) {
|
||||
throw new Error("Upload did not return a file URL");
|
||||
}
|
||||
onChange(url);
|
||||
toast.success("Icon uploaded");
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to upload icon";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[disabled, uploading, onChange, setBusy],
|
||||
);
|
||||
|
||||
const handleFileInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (file) void processFile(file);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled && !uploading) setDragActive(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
if (disabled || uploading) return;
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) void processFile(file);
|
||||
};
|
||||
|
||||
const zoneDisabled = disabled || uploading;
|
||||
const showSpinner = uploading;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
<label className="text-[15px] font-medium text-grayScale-700 md:text-sm">
|
||||
Icon
|
||||
</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,.jpg,.jpeg,.png"
|
||||
className="sr-only"
|
||||
onChange={handleFileInputChange}
|
||||
disabled={zoneDisabled}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={zoneDisabled}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-[#9E289133] bg-white p-10 text-center transition-colors",
|
||||
"hover:border-[#9E289180] hover:bg-grayScale-50/30",
|
||||
dragActive && "border-[#9E2891] bg-[#9E289108]",
|
||||
zoneDisabled && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
{showSpinner ? (
|
||||
<p className="text-sm font-medium text-grayScale-600">Uploading…</p>
|
||||
) : (
|
||||
<>
|
||||
<CloudUpload
|
||||
className="mb-4 h-10 w-10 text-[#9E2891]"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden
|
||||
/>
|
||||
<p className="text-sm">
|
||||
<span className="font-bold text-[#9E2891]">Click to upload</span>{" "}
|
||||
<span className="text-grayScale-500">or paste a URL below</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-medium uppercase tracking-wider text-grayScale-400">
|
||||
JPG, PNG (MAX 5 MB)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="https://…"
|
||||
className="h-12 rounded-xl"
|
||||
disabled={disabled || uploading}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { useState } from "react";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import {
|
||||
DEFAULT_PREVIEW_MAX_SECONDS,
|
||||
formatPreviewLength,
|
||||
} from "../../../lib/videoPreview";
|
||||
|
||||
type Props = {
|
||||
src: string;
|
||||
maxSeconds?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops direct file playback after the first N seconds (admin short preview).
|
||||
*/
|
||||
export function PreviewLimitedFileVideo({
|
||||
src,
|
||||
maxSeconds = DEFAULT_PREVIEW_MAX_SECONDS,
|
||||
}: Props) {
|
||||
const [capped, setCapped] = useState(false);
|
||||
const previewLengthLabel = formatPreviewLength(maxSeconds);
|
||||
|
||||
const onTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||
const el = e.currentTarget;
|
||||
if (el.currentTime >= maxSeconds) {
|
||||
el.pause();
|
||||
if (el.currentTime > maxSeconds) {
|
||||
el.currentTime = maxSeconds;
|
||||
}
|
||||
setCapped(true);
|
||||
} else {
|
||||
setCapped(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSeeking = (e: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||
const el = e.currentTarget;
|
||||
if (el.currentTime > maxSeconds) {
|
||||
el.currentTime = maxSeconds;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<video
|
||||
controls
|
||||
playsInline
|
||||
className="aspect-video w-full object-contain"
|
||||
src={src}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onSeeking={onSeeking}
|
||||
onPlay={() => setCapped(false)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/90 via-black/50 to-transparent px-3 py-2.5 text-center text-[11px] font-semibold",
|
||||
capped ? "text-amber-200" : "text-white/95",
|
||||
)}
|
||||
>
|
||||
{capped
|
||||
? `Preview stopped at ${previewLengthLabel} · rewind to rewatch the clip`
|
||||
: `Short clip · playback stops at ${previewLengthLabel}`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,14 +1,43 @@
|
|||
import { MoreVertical, Edit2, Play } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { MoreVertical, Edit2, Play, Pencil, Trash2 } from "lucide-react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { isAdminOrSuperAdminRole } from "../../../lib/sessionRole";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import {
|
||||
applyShortPreviewToEmbedUrl,
|
||||
DEFAULT_PREVIEW_MAX_SECONDS,
|
||||
formatPreviewLength,
|
||||
getVideoPreview,
|
||||
} from "../../../lib/videoPreview";
|
||||
import { PreviewLimitedFileVideo } from "./PreviewLimitedFileVideo";
|
||||
|
||||
interface VideoCardProps {
|
||||
id: string;
|
||||
id?: string | number;
|
||||
title: string;
|
||||
duration: string;
|
||||
status: "Draft" | "Published";
|
||||
thumbnailGradient: string;
|
||||
/** Omits the duration chip when not provided (e.g. API has no length yet). */
|
||||
duration?: string;
|
||||
/** When omitted, shows a neutral "Lesson" chip and no Publish button. */
|
||||
status?: "Draft" | "Published";
|
||||
thumbnailGradient?: string;
|
||||
thumbnailUrl?: string | null;
|
||||
/**
|
||||
* When set, the hover play control opens a preview (Vimeo, YouTube, or direct
|
||||
* video file) in a dialog.
|
||||
*/
|
||||
videoUrl?: string;
|
||||
/**
|
||||
* When true, shows edit/delete in the top-right of the thumbnail (same
|
||||
* hover pattern as module cards) and removes the footer + overflow menu.
|
||||
*/
|
||||
hoverModuleActions?: boolean;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
onPublish?: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -16,84 +45,330 @@ export function VideoCard({
|
|||
title,
|
||||
duration,
|
||||
status,
|
||||
thumbnailGradient,
|
||||
thumbnailGradient = "from-[#CBD5E1] to-[#94A3B8]",
|
||||
thumbnailUrl,
|
||||
videoUrl,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onPublish,
|
||||
hoverModuleActions = false,
|
||||
}: VideoCardProps) {
|
||||
const [thumbFailed, setThumbFailed] = useState(false);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
/** Iframe players ignore URL limits in many cases — unmount after real time. */
|
||||
const [iframeSessionDone, setIframeSessionDone] = useState(false);
|
||||
const [iframeSessionKey, setIframeSessionKey] = useState(0);
|
||||
const useGradient = !thumbnailUrl?.trim() || thumbFailed;
|
||||
const videoPreview = useMemo(
|
||||
() => (videoUrl?.trim() ? getVideoPreview(videoUrl) : { kind: "none" as const }),
|
||||
[videoUrl],
|
||||
);
|
||||
const limitedEmbedSrc = useMemo(() => {
|
||||
if (videoPreview.kind !== "iframe") return null;
|
||||
return applyShortPreviewToEmbedUrl(
|
||||
videoPreview.src,
|
||||
videoPreview.label,
|
||||
DEFAULT_PREVIEW_MAX_SECONDS,
|
||||
);
|
||||
}, [videoPreview]);
|
||||
const canPreview = Boolean(videoUrl?.trim());
|
||||
const previewLengthLabel = formatPreviewLength(
|
||||
DEFAULT_PREVIEW_MAX_SECONDS,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewOpen) {
|
||||
setIframeSessionDone(false);
|
||||
return;
|
||||
}
|
||||
if (videoPreview.kind !== "iframe" || !limitedEmbedSrc) {
|
||||
return;
|
||||
}
|
||||
if (iframeSessionDone) {
|
||||
return;
|
||||
}
|
||||
const ms = DEFAULT_PREVIEW_MAX_SECONDS * 1000;
|
||||
const id = window.setTimeout(() => {
|
||||
setIframeSessionDone(true);
|
||||
}, ms);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [
|
||||
previewOpen,
|
||||
videoPreview.kind,
|
||||
limitedEmbedSrc,
|
||||
iframeSessionDone,
|
||||
]);
|
||||
|
||||
const handlePreviewOpenChange = (open: boolean) => {
|
||||
setPreviewOpen(open);
|
||||
if (!open) {
|
||||
setIframeSessionDone(false);
|
||||
setIframeSessionKey((k) => k + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group bg-white rounded-[24px] border border-grayScale-50 overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 flex flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"group relative bg-white rounded-[24px] border border-grayScale-50 overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 flex flex-col",
|
||||
)}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-44 w-full bg-gradient-to-br",
|
||||
thumbnailGradient,
|
||||
"relative h-44 w-full overflow-hidden",
|
||||
useGradient && "bg-gradient-to-br",
|
||||
useGradient && thumbnailGradient,
|
||||
!useGradient && "bg-grayScale-100",
|
||||
)}
|
||||
>
|
||||
{/* Duration Badge */}
|
||||
<div className="absolute bottom-3 right-3 bg-black/70 text-white text-[11px] font-bold px-2 py-1 rounded-md backdrop-blur-sm">
|
||||
{duration}
|
||||
</div>
|
||||
{/* Play Overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/10">
|
||||
<div className="h-12 w-12 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center border border-white/30">
|
||||
<Play className="h-6 w-6 text-white fill-current" />
|
||||
{hoverModuleActions && (onEdit || onDelete) ? (
|
||||
<div
|
||||
className="absolute right-2 top-2 z-20 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto"
|
||||
>
|
||||
{onEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
|
||||
aria-label={`Edit ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
|
||||
aria-label={`Delete ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{!useGradient && thumbnailUrl ? (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
onError={() => setThumbFailed(true)}
|
||||
/>
|
||||
) : null}
|
||||
{/* Duration Badge */}
|
||||
{duration ? (
|
||||
<div className="absolute bottom-3 right-3 z-10 bg-black/70 text-white text-[11px] font-bold px-2 py-1 rounded-md backdrop-blur-sm">
|
||||
{duration}
|
||||
</div>
|
||||
) : null}
|
||||
{/* Play: opens preview dialog when videoUrl is set */}
|
||||
{canPreview ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 z-[8] flex cursor-pointer items-center justify-center bg-gradient-to-b from-black/0 via-black/20 to-black/30 opacity-0 transition-all duration-300 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setPreviewOpen(true);
|
||||
}}
|
||||
aria-label={`Play preview: ${title}`}
|
||||
>
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full border border-white/40 bg-white/20 shadow-lg backdrop-blur-md transition-transform duration-300 group-hover:scale-105 group-hover:border-white/50 group-hover:bg-white/30">
|
||||
<Play className="h-6 w-6 text-white" fill="currentColor" />
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="pointer-events-none absolute inset-0 z-[5] flex items-center justify-center bg-black/10 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="h-12 w-12 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center border border-white/30">
|
||||
<Play className="h-6 w-6 text-white fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={previewOpen} onOpenChange={handlePreviewOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-4xl w-[min(100vw-1.5rem,56rem)] gap-0 overflow-hidden rounded-2xl border border-grayScale-200 p-0 shadow-2xl"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="border-b border-grayScale-100 bg-gradient-to-r from-[#F8FAFC] to-white px-5 py-4 pr-12 sm:px-6 sm:pr-14">
|
||||
<DialogHeader className="space-y-0.5 p-0 text-left">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-brand-500">
|
||||
Short preview
|
||||
</p>
|
||||
<DialogTitle className="line-clamp-2 text-left text-base font-bold leading-snug text-grayScale-900 sm:text-lg">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<p className="pt-0.5 text-left text-xs font-medium text-grayScale-500">
|
||||
The player closes automatically after {previewLengthLabel} in
|
||||
this window (YouTube/Vimeo can’t be trimmed reliably). For the
|
||||
full lesson, use your LMS app.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<div className="bg-black">
|
||||
{videoPreview.kind === "iframe" && limitedEmbedSrc ? (
|
||||
iframeSessionDone ? (
|
||||
<div className="flex min-h-[220px] flex-col items-center justify-center gap-3 bg-gradient-to-b from-grayScale-900 to-grayScale-950 px-6 py-10 text-center">
|
||||
<p className="text-sm font-semibold text-white">
|
||||
Preview time in this window has ended
|
||||
</p>
|
||||
<p className="max-w-sm text-xs text-white/60">
|
||||
The embed is removed after {previewLengthLabel} of real time
|
||||
so the full video is not available here.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="mt-1 font-bold"
|
||||
onClick={() => {
|
||||
setIframeSessionDone(false);
|
||||
setIframeSessionKey((k) => k + 1);
|
||||
}}
|
||||
>
|
||||
Start preview again
|
||||
</Button>
|
||||
{videoUrl && isAdminOrSuperAdminRole() ? (
|
||||
<a
|
||||
href={videoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs font-semibold text-brand-300 underline-offset-2 hover:underline"
|
||||
>
|
||||
Open full video in new tab
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative aspect-video w-full">
|
||||
<iframe
|
||||
key={`${iframeSessionKey}-${limitedEmbedSrc}`}
|
||||
src={limitedEmbedSrc}
|
||||
title={`${videoPreview.label} preview: ${title}`}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
allow="autoplay; fullscreen; picture-in-picture; encrypted-media"
|
||||
allowFullScreen
|
||||
/>
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/90 via-black/50 to-transparent px-3 py-2.5 text-center text-[11px] font-semibold text-white/95">
|
||||
Stops in {previewLengthLabel} (hard limit)
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : videoPreview.kind === "video" ? (
|
||||
<PreviewLimitedFileVideo
|
||||
src={videoPreview.src}
|
||||
maxSeconds={DEFAULT_PREVIEW_MAX_SECONDS}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex min-h-[200px] flex-col items-center justify-center gap-2 bg-grayScale-900 px-6 py-10 text-center">
|
||||
<p className="text-sm font-medium text-white/90">
|
||||
This link can’t be played inline
|
||||
</p>
|
||||
<p className="max-w-sm text-xs text-white/50">
|
||||
Use a Vimeo, YouTube, or direct URL to a video file (e.g. MP4)
|
||||
for an embedded preview.
|
||||
</p>
|
||||
{videoUrl ? (
|
||||
<a
|
||||
href={videoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 text-sm font-semibold text-brand-300 underline-offset-2 hover:underline"
|
||||
>
|
||||
Open in new tab
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5 space-y-4 flex-1 flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
hoverModuleActions ? "justify-start" : "justify-between",
|
||||
)}
|
||||
>
|
||||
{/* Status Badge */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider border",
|
||||
status === "Published"
|
||||
? "bg-[#ECFDF5] text-[#059669] border-[#D1FAE5]"
|
||||
: "bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]",
|
||||
)}
|
||||
>
|
||||
{status ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full",
|
||||
status === "Published" ? "bg-[#10B981]" : "bg-[#9CA3AF]",
|
||||
"flex items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider border min-w-0",
|
||||
status === "Published"
|
||||
? "bg-[#ECFDF5] text-[#059669] border-[#D1FAE5]"
|
||||
: "bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]",
|
||||
)}
|
||||
/>
|
||||
{status}
|
||||
</div>
|
||||
{/* Menu */}
|
||||
<button className="h-8 w-8 flex items-center justify-center rounded-full hover:bg-grayScale-50 transition-colors text-grayScale-400">
|
||||
<MoreVertical className="h-5 w-5" />
|
||||
</button>
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full flex-shrink-0",
|
||||
status === "Published" ? "bg-[#10B981]" : "bg-[#9CA3AF]",
|
||||
)}
|
||||
/>
|
||||
{status}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider border border-[#E5E7EB] bg-grayScale-50 text-grayScale-500">
|
||||
<div className="h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#9CA3AF]" />
|
||||
Lesson
|
||||
</div>
|
||||
)}
|
||||
{!hoverModuleActions ? (
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 w-8 flex flex-shrink-0 items-center justify-center rounded-full hover:bg-grayScale-50 transition-colors text-grayScale-400"
|
||||
>
|
||||
<MoreVertical className="h-5 w-5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<h3 className="text-[16px] font-medium text-grayScale-900 line-clamp-2 leading-snug">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="pt-2 space-y-3 mt-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
className="w-full h-10 rounded-xl border-grayScale-200 text-grayScale-600 font-bold hover:bg-grayScale-50 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
disabled={status === "Published"}
|
||||
onClick={onPublish}
|
||||
className={cn(
|
||||
"w-full h-10 rounded-xl font-bold transition-all shadow-sm",
|
||||
status === "Published"
|
||||
? "bg-[#E9D5E5] text-white opacity-100 cursor-default"
|
||||
: "bg-brand-500 text-white hover:bg-brand-600 shadow-brand-500/10",
|
||||
)}
|
||||
>
|
||||
{status === "Published" ? "Published" : "Publish"}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Actions (footer) — not used for API lesson cards with hover tools */}
|
||||
{!hoverModuleActions ? (
|
||||
<div className="pt-2 space-y-3 mt-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
className="w-full h-10 rounded-xl border-grayScale-200 text-grayScale-600 font-bold hover:bg-grayScale-50 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
{status ? (
|
||||
<Button
|
||||
disabled={status === "Published"}
|
||||
onClick={onPublish}
|
||||
className={cn(
|
||||
"w-full h-10 rounded-xl font-bold transition-all shadow-sm",
|
||||
status === "Published"
|
||||
? "bg-[#E9D5E5] text-white opacity-100 cursor-default"
|
||||
: "bg-brand-500 text-white hover:bg-brand-600 shadow-brand-500/10",
|
||||
)}
|
||||
>
|
||||
{status === "Published" ? "Published" : "Publish"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,86 +1,167 @@
|
|||
import {
|
||||
Rocket,
|
||||
Edit2,
|
||||
Layout,
|
||||
Volume2,
|
||||
Settings,
|
||||
Maximize2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Rocket, Edit2, Link2, Video } from "lucide-react";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import type { AddLessonFormData } from "../../AddVideoFlow";
|
||||
import {
|
||||
applyShortPreviewToEmbedUrl,
|
||||
DEFAULT_PREVIEW_MAX_SECONDS,
|
||||
formatPreviewLength,
|
||||
getVideoPreview,
|
||||
resolveThumbnailForPreview,
|
||||
} from "../../../../lib/videoPreview";
|
||||
import { PreviewLimitedFileVideo } from "../PreviewLimitedFileVideo";
|
||||
|
||||
interface ReviewPublishStepProps {
|
||||
formData: any;
|
||||
formData: AddLessonFormData;
|
||||
prevStep: () => void;
|
||||
setIsPublished: (val: boolean) => void;
|
||||
onPublish: () => void;
|
||||
publishing: boolean;
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
if (s.length <= max) return s;
|
||||
return `${s.slice(0, max)}…`;
|
||||
}
|
||||
|
||||
export function ReviewPublishStep({
|
||||
formData,
|
||||
prevStep,
|
||||
setIsPublished,
|
||||
onPublish,
|
||||
publishing,
|
||||
}: ReviewPublishStepProps) {
|
||||
const [thumbBroken, setThumbBroken] = useState(false);
|
||||
const videoPreview = useMemo(
|
||||
() => getVideoPreview(formData.videoUrl),
|
||||
[formData.videoUrl],
|
||||
);
|
||||
const limitedEmbedSrc = useMemo(() => {
|
||||
if (videoPreview.kind !== "iframe") return null;
|
||||
return applyShortPreviewToEmbedUrl(
|
||||
videoPreview.src,
|
||||
videoPreview.label,
|
||||
DEFAULT_PREVIEW_MAX_SECONDS,
|
||||
);
|
||||
}, [videoPreview]);
|
||||
const previewLengthLabel = formatPreviewLength(DEFAULT_PREVIEW_MAX_SECONDS);
|
||||
const thumbSrc = useMemo(
|
||||
() => resolveThumbnailForPreview(formData.thumbnailUrl),
|
||||
[formData.thumbnailUrl],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setThumbBroken(false);
|
||||
}, [thumbSrc]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500 pb-20">
|
||||
{/* 1. Video Preview Card */}
|
||||
<div className="bg-white rounded-[16px] border border-grayScale-50 shadow-sm overflow-hidden">
|
||||
<div className="px-8 py-5 border-b border-grayScale-50 flex items-center justify-between bg-white">
|
||||
<div className="px-8 py-5 border-b border-grayScale-50 flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between bg-white">
|
||||
<h3 className="text-[17px] font-bold text-grayScale-900">
|
||||
Video Preview
|
||||
Media preview
|
||||
</h3>
|
||||
<span className="bg-[#FAF5FF] text-brand-500 text-[10px] font-bold px-3 py-1.5 rounded-[6px] tracking-wider uppercase border border-brand-100/50">
|
||||
PROCESSED
|
||||
</span>
|
||||
<p className="text-xs font-medium text-grayScale-500">
|
||||
Video: short clip (first {previewLengthLabel} only)
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-10 flex items-center justify-center bg-[#F8FAFC]/30">
|
||||
<div className="relative w-full max-w-4xl aspect-video rounded-[12px] overflow-hidden bg-black shadow-2xl group border-4 border-white">
|
||||
{/* Mock Player Control Overlays */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center border border-white/30 cursor-pointer hover:scale-110 transition-transform">
|
||||
<div className="w-0 h-0 border-t-[10px] border-t-transparent border-l-[18px] border-l-white border-b-[10px] border-b-transparent ml-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Controls — Matching Image 1884 */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/95 via-black/40 to-transparent space-y-4">
|
||||
{/* Row 1: Seeker and Timestamps */}
|
||||
<div className="flex items-center gap-4 text-white">
|
||||
<span className="text-[13px] font-medium opacity-90">0:00</span>
|
||||
<div className="flex-1 h-1 bg-white/20 rounded-full relative cursor-pointer overflow-hidden group/seeker">
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 bg-brand-500 rounded-full"
|
||||
style={{ width: "40%" }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[13px] font-medium opacity-90">
|
||||
12:30
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Icons */}
|
||||
<div className="flex items-center justify-between text-white">
|
||||
<div className="flex items-center gap-6">
|
||||
<Volume2 className="h-[22px] w-[22px] opacity-90 cursor-pointer hover:opacity-100 transition-opacity" />
|
||||
<div className="h-5 w-6 border-2 border-white rounded-[3px] flex items-center justify-center text-[9px] font-bold opacity-90 cursor-pointer hover:opacity-100 transition-opacity">
|
||||
CC
|
||||
<div className="p-8">
|
||||
<div className="flex flex-col gap-10 xl:flex-row xl:items-start xl:gap-10">
|
||||
{/* Video preview */}
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||
Video
|
||||
</span>
|
||||
{formData.videoUrl ? (
|
||||
<div className="space-y-3">
|
||||
{videoPreview.kind === "iframe" && limitedEmbedSrc ? (
|
||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-black shadow-sm">
|
||||
<div className="relative aspect-video w-full max-w-4xl">
|
||||
<iframe
|
||||
key={limitedEmbedSrc}
|
||||
src={limitedEmbedSrc}
|
||||
title={`${videoPreview.label} lesson preview`}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/90 via-black/50 to-transparent px-3 py-2.5 text-center text-[11px] font-semibold text-white/95">
|
||||
Short clip · max {previewLengthLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : videoPreview.kind === "video" ? (
|
||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-black shadow-sm">
|
||||
<PreviewLimitedFileVideo
|
||||
src={videoPreview.src}
|
||||
maxSeconds={DEFAULT_PREVIEW_MAX_SECONDS}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[200px] flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-grayScale-200 bg-grayScale-50/80 px-6 py-10 text-center">
|
||||
<Video className="h-10 w-10 text-grayScale-300" />
|
||||
<p className="text-sm font-medium text-grayScale-600">
|
||||
No inline preview for this URL
|
||||
</p>
|
||||
<p className="text-xs text-grayScale-500 max-w-md">
|
||||
Use a Vimeo, YouTube, or direct link to a video file
|
||||
(MP4, WebM, …) to see a player here. The URL below will
|
||||
still be saved.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start gap-2 text-[13px] text-grayScale-600 break-all">
|
||||
<Link2 className="h-3.5 w-3.5 mt-0.5 flex-shrink-0 text-grayScale-400" />
|
||||
{truncate(formData.videoUrl, 220)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<Settings className="h-5 w-5 opacity-90 cursor-pointer hover:opacity-100 transition-opacity" />
|
||||
<Maximize2 className="h-5 w-5 opacity-90 cursor-pointer hover:opacity-100 transition-opacity" />
|
||||
) : (
|
||||
<p className="text-grayScale-400 text-sm">—</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail preview */}
|
||||
<div className="w-full shrink-0 space-y-3 xl:max-w-[360px]">
|
||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||
Thumbnail
|
||||
</span>
|
||||
{formData.thumbnailUrl && thumbSrc ? (
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-100 shadow-sm">
|
||||
<div className="relative aspect-video w-full max-w-md">
|
||||
{!thumbBroken ? (
|
||||
<img
|
||||
src={thumbSrc}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
onError={() => setThumbBroken(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex aspect-video w-full max-w-md items-center justify-center bg-grayScale-200 px-4 text-center text-xs text-grayScale-500">
|
||||
Thumbnail could not be loaded. URL will still be
|
||||
saved.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[12px] text-grayScale-500 break-all">
|
||||
{truncate(formData.thumbnailUrl, 160)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-grayScale-400 text-sm">—</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Content Details Card */}
|
||||
<div className="bg-white rounded-[16px] border border-grayScale-50 shadow-sm overflow-hidden">
|
||||
<div className="px-8 py-5 border-b border-grayScale-50 flex items-center justify-between bg-white">
|
||||
<h3 className="text-[16px] font-bold text-grayScale-900">
|
||||
Content Details
|
||||
Content details
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={prevStep}
|
||||
className="flex items-center gap-2 text-brand-500 font-bold text-sm hover:opacity-80 transition-opacity"
|
||||
>
|
||||
|
|
@ -90,70 +171,29 @@ export function ReviewPublishStep({
|
|||
</div>
|
||||
|
||||
<div className="p-8 space-y-10">
|
||||
{/* Metadata Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div className="space-y-2">
|
||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||
TITLE
|
||||
</span>
|
||||
<p className="text-[15px] font-medium text-grayScale-900">
|
||||
{formData.title || "Introduction to Past Tense"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||
ASSIGNED MODULE
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Layout className="h-4 w-4 text-grayScale-400" />
|
||||
<p className="text-[14px] font-medium text-grayScale-700">
|
||||
Grammar Basics - Level 1
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||
TEACHER NAME
|
||||
</span>
|
||||
<p className="text-[15px] font-medium text-grayScale-600">
|
||||
Abebe Kebede
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||
FILE SIZE
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[15px] font-bold text-grayScale-900">
|
||||
245 MB
|
||||
</span>
|
||||
<span className="text-[13px] text-grayScale-400 font-medium">
|
||||
(1080p MP4)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||
Title
|
||||
</span>
|
||||
<p className="text-[15px] font-medium text-grayScale-900">
|
||||
{formData.title || "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description Section */}
|
||||
<div className="space-y-3">
|
||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||
DESCRIPTION
|
||||
Description
|
||||
</span>
|
||||
<div
|
||||
className="text-[14px] text-grayScale-600 leading-relaxed max-w-4xl"
|
||||
className="text-[14px] text-grayScale-600 leading-relaxed max-w-4xl"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
formData.description ||
|
||||
"This video covers the fundamental rules of forming the past tense in English, focusing on regular verbs ending in -ed. Suitable for beginners. Includes examples and common pitfalls.",
|
||||
formData.description || "<p class='text-grayScale-400'>—</p>",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gradient Divider */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-0 flex items-center"
|
||||
|
|
@ -164,18 +204,17 @@ export function ReviewPublishStep({
|
|||
<div className="relative flex justify-center">
|
||||
<div
|
||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
||||
style={{
|
||||
background: "gray",
|
||||
}}
|
||||
style={{ background: "gray" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Normal Footer (Inside Card) */}
|
||||
<div className="px-8 py-6 border-t border-grayScale-50 flex items-center justify-between bg-white">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={prevStep}
|
||||
disabled={publishing}
|
||||
className="h-10 px-8 rounded-xl border-grayScale-200 font-bold text-grayScale-600 hover:bg-grayScale-50 transition-all shadow-sm"
|
||||
>
|
||||
Back
|
||||
|
|
@ -183,17 +222,24 @@ export function ReviewPublishStep({
|
|||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12 px-8 rounded-[6px] border-grayScale-100 font-bold text-grayScale-600 hover:bg-grayScale-50 transition-all shadow-sm"
|
||||
disabled={publishing}
|
||||
onClick={() =>
|
||||
toast.info("Drafts are not supported yet. Use Create lesson.")
|
||||
}
|
||||
>
|
||||
Save as Draft
|
||||
Save as draft
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsPublished(true)}
|
||||
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold text-white shadow-brand-500/20 transition-all flex items-center gap-2.5"
|
||||
type="button"
|
||||
onClick={onPublish}
|
||||
disabled={publishing}
|
||||
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold text-white shadow-brand-500/20 transition-all flex items-center gap-2.5 disabled:opacity-60"
|
||||
>
|
||||
<Rocket className="h-4 w-4" />
|
||||
Publish Now
|
||||
{publishing ? "Creating…" : "Create lesson"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,32 +1,36 @@
|
|||
import { useRef, useEffect } from "react";
|
||||
import {
|
||||
Video,
|
||||
List,
|
||||
Link as LinkIcon,
|
||||
Lightbulb,
|
||||
ChevronRight,
|
||||
ImageIcon,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
useRef,
|
||||
useEffect,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { List, Link as LinkIcon, Lightbulb, ArrowRight } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
import { Input } from "../../../../components/ui/input";
|
||||
import { Select } from "../../../../components/ui/select";
|
||||
import type { AddLessonFormData } from "../../AddVideoFlow";
|
||||
import { LessonMediaUploadField } from "../LessonMediaUploadField";
|
||||
|
||||
function isDescriptionEmpty(raw: string): boolean {
|
||||
if (!raw?.trim()) return true;
|
||||
const t = raw.replace(/<[^>]+>/g, "").replace(/ /g, " ").trim();
|
||||
return t.length === 0;
|
||||
}
|
||||
|
||||
interface VideoDetailStepProps {
|
||||
formData: any;
|
||||
setFormData: (data: any) => void;
|
||||
nextStep: () => void;
|
||||
formData: AddLessonFormData;
|
||||
setFormData: Dispatch<SetStateAction<AddLessonFormData>>;
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
export function VideoDetailStep({
|
||||
formData,
|
||||
setFormData,
|
||||
nextStep,
|
||||
onContinue,
|
||||
}: VideoDetailStepProps) {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const isInternalChange = useRef(false);
|
||||
|
||||
// Initialize editor content only once or when needed from outside
|
||||
useEffect(() => {
|
||||
if (editorRef.current && !isInternalChange.current) {
|
||||
editorRef.current.innerHTML = formData.description || "";
|
||||
|
|
@ -41,8 +45,10 @@ export function VideoDetailStep({
|
|||
const syncState = () => {
|
||||
if (editorRef.current) {
|
||||
isInternalChange.current = true;
|
||||
setFormData({ ...formData, description: editorRef.current.innerHTML });
|
||||
// Reset after a short delay to allow exterior updates if any (e.g., from step change)
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
description: editorRef.current!.innerHTML,
|
||||
}));
|
||||
setTimeout(() => {
|
||||
isInternalChange.current = false;
|
||||
}, 0);
|
||||
|
|
@ -53,50 +59,57 @@ export function VideoDetailStep({
|
|||
syncState();
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (editorRef.current) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
description: editorRef.current!.innerHTML,
|
||||
}));
|
||||
}
|
||||
if (!formData.title.trim()) {
|
||||
toast.error("Title is required");
|
||||
return;
|
||||
}
|
||||
if (!formData.videoUrl.trim()) {
|
||||
toast.error("Add a video URL or upload a video");
|
||||
return;
|
||||
}
|
||||
if (!formData.thumbnailUrl.trim()) {
|
||||
toast.error("Add a thumbnail or upload an image");
|
||||
return;
|
||||
}
|
||||
const descHtml = editorRef.current?.innerHTML ?? formData.description;
|
||||
if (isDescriptionEmpty(descHtml)) {
|
||||
toast.error("Description is required");
|
||||
return;
|
||||
}
|
||||
onContinue();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700 max-w-[1200px] mx-auto pb-20">
|
||||
{/* Single Unified Card for Everything */}
|
||||
<div className="bg-white rounded-[24px] border border-grayScale-50 p-10 shadow-sm space-y-8">
|
||||
{/* 1. Upload Video Section */}
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-[20px] font-bold text-grayScale-900 ml-1">
|
||||
Upload Video
|
||||
Video
|
||||
</h3>
|
||||
<div className="relative group cursor-pointer">
|
||||
<div className="flex flex-col items-center justify-center rounded-[20px] border-2 border-dashed border-[#E2E8F0] bg-[#F8FAFC]/30 p-14 transition-all hover:border-brand-200 hover:bg-brand-50/5">
|
||||
<div className="h-16 w-16 rounded-full bg-white shadow-sm flex items-center justify-center mb-6">
|
||||
<div className="h-10 w-10 rounded-full bg-[#FAF5FF] flex items-center justify-center">
|
||||
<div className="h-6 w-6 relative flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-brand-500/10 rounded-full blur-sm" />
|
||||
<Video className="h-5 w-5 text-brand-500 relative" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h4 className="text-[17px] text-grayScale-900 mb-2">
|
||||
Drag and drop video files here
|
||||
</h4>
|
||||
<p className="text-grayScale-400 font-medium text-[13px] mb-8">
|
||||
MP4, MOV, WebM. Max size 2GB.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 w-full max-w-[200px] mb-8">
|
||||
<div className="flex-1 h-[1px] bg-grayScale-200" />
|
||||
<span className="text-[12px] font-bold text-grayScale-300 uppercase tracking-widest">
|
||||
OR
|
||||
</span>
|
||||
<div className="flex-1 h-[1px] bg-grayScale-200" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-11 px-8 rounded-xl border-grayScale-200 bg-white font-bold text-brand-500 hover:border-brand-500 hover:bg-brand-50 transition-all shadow-sm text-sm"
|
||||
>
|
||||
Browse Files
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-grayScale-500 ml-1 max-w-2xl">
|
||||
Upload a file or paste a link (Vimeo, hosted file, etc.). Files are
|
||||
sent to your storage via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-[11px]">
|
||||
POST /files/upload
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
<LessonMediaUploadField
|
||||
kind="video"
|
||||
value={formData.videoUrl}
|
||||
onChange={(v) =>
|
||||
setFormData((prev) => ({ ...prev, videoUrl: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* Gradient Divider */}
|
||||
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-0 flex items-center"
|
||||
|
|
@ -107,75 +120,57 @@ export function VideoDetailStep({
|
|||
<div className="relative flex justify-center">
|
||||
<div
|
||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
||||
style={{
|
||||
background: "gray",
|
||||
}}
|
||||
style={{ background: "gray" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Form & Side Panel Grid */}
|
||||
<div className="flex flex-col lg:flex-row gap-12 items-start">
|
||||
{/* Left Column: Title, Order, Description */}
|
||||
<div className="flex-1 w-full space-y-10">
|
||||
<div className="space-y-3">
|
||||
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
|
||||
Video Title
|
||||
Lesson title
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g., Introduction to Past Tense Verbs"
|
||||
placeholder="e.g. Introduction to Past Tense"
|
||||
className="h-12 rounded-xl border-grayScale-200 bg-white px-6 text-[15px] text-grayScale-800 placeholder:text-grayScale-500 focus:border-brand-500 font-medium transition-all shadow-sm"
|
||||
value={formData.title}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, title: e.target.value })
|
||||
setFormData((prev) => ({ ...prev, title: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
|
||||
Video Order
|
||||
</label>
|
||||
<Select
|
||||
className="h-12 rounded-xl border-grayScale-200 bg-white px-6 text-[15px] text-grayScale-800 font-medium cursor-pointer focus:border-brand-500 shadow-sm"
|
||||
value={formData.order}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, order: (e.target as any).value })
|
||||
}
|
||||
>
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
|
||||
Description
|
||||
</label>
|
||||
<div className="rounded-xl border border-grayScale-200 bg-white overflow-hidden flex flex-col min-h-[200px] shadow-sm focus-within:border-brand-200 transition-all">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1 bg-[#F8FAFC]">
|
||||
<div className="flex items-center gap-1 w-fit bg-transparent px-2 py-1 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCommand("bold")}
|
||||
className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all font-serif font-bold text-[17px] pb-0.5 active:bg-grayScale-50"
|
||||
>
|
||||
B
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCommand("italic")}
|
||||
className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all font-serif italic text-[17px] pr-0.5 active:bg-grayScale-50"
|
||||
>
|
||||
I
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCommand("insertUnorderedList")}
|
||||
className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all active:bg-grayScale-50"
|
||||
>
|
||||
<List className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const url = prompt("Enter URL:");
|
||||
if (url) handleCommand("createLink", url);
|
||||
|
|
@ -188,12 +183,9 @@ export function VideoDetailStep({
|
|||
</div>
|
||||
|
||||
<div className="relative p-6 flex-1">
|
||||
{(!formData.description ||
|
||||
formData.description === "<br>" ||
|
||||
formData.description === "" ||
|
||||
formData.description === "<div><br></div>") && (
|
||||
{isDescriptionEmpty(formData.description) && (
|
||||
<div className="absolute top-6 left-6 text-grayScale-300 font-medium text-[15px] pointer-events-none">
|
||||
Provide a brief summary of what the student will learn...
|
||||
What will students learn in this lesson?
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
|
|
@ -207,59 +199,44 @@ export function VideoDetailStep({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Thumbnail, Pro Tip */}
|
||||
<div className="w-full lg:w-[320px] space-y-5">
|
||||
{/* Thumbnail Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1 ml-1">
|
||||
<h3 className="text-[14px] font-medium text-grayScale-900">
|
||||
Thumbnail
|
||||
</h3>
|
||||
<p className="text-[12px] text-grayScale-400 font-medium leading-relaxed">
|
||||
Upload your video thumbnail. 1280×720px recommended.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative group cursor-pointer aspect-video">
|
||||
<div className="h-full w-full flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-grayScale-200 bg-[#F8FAFC]/50 p-6 transition-all group-hover:border-brand-200">
|
||||
<div className="h-10 w-10 flex items-center justify-center mb-3">
|
||||
<ImageIcon className="h-7 w-7 text-grayScale-400" />
|
||||
</div>
|
||||
<p className="text-[13px] font-bold text-brand-400">
|
||||
Click to upload
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pro Tip Section */}
|
||||
<div className="w-full lg:w-[360px] space-y-5">
|
||||
<LessonMediaUploadField
|
||||
kind="thumbnail"
|
||||
value={formData.thumbnailUrl}
|
||||
onChange={(v) =>
|
||||
setFormData((prev) => ({ ...prev, thumbnailUrl: v }))
|
||||
}
|
||||
/>
|
||||
<div className="bg-brand-500/5 flex items-start gap-3 rounded-xl border border-[#F3E8FF] p-6 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 flex-shrink-0 flex items-center justify-center">
|
||||
<Lightbulb className="h-4 w-4 text-brand-50" fill="#A855F7" />
|
||||
<Lightbulb
|
||||
className="h-4 w-4 text-brand-50"
|
||||
fill="#A855F7"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative top-[-10px]">
|
||||
<h3 className="text-[14px] font-bold text-grayScale-900">
|
||||
Pro Tip
|
||||
Pro tip
|
||||
</h3>
|
||||
<p className="text-[12px] text-grayScale-700 font-medium leading-relaxed">
|
||||
Short, descriptive titles work best. Include keywords like
|
||||
"Grammar" or "Vocabulary" to help students find your content.
|
||||
Use clear titles and a thumbnail that matches the lesson. The
|
||||
lesson is created with{" "}
|
||||
<code className="rounded bg-white/80 px-1 text-[10px]">
|
||||
POST /modules/:moduleId/lessons
|
||||
</code>{" "}
|
||||
when you publish.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer (Inside Card Container) */}
|
||||
<div className="pt-5 border-t border-grayScale-200 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[14px] font-medium text-grayScale-600">
|
||||
Last saved: Just now
|
||||
</span>
|
||||
</div>
|
||||
<div className="pt-5 border-t border-grayScale-200 flex items-center justify-end">
|
||||
<Button
|
||||
onClick={nextStep}
|
||||
type="button"
|
||||
onClick={handleContinue}
|
||||
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold text-white transition-all flex items-center gap-2 text-sm group active:scale-95"
|
||||
>
|
||||
Continue
|
||||
|
|
|
|||
|
|
@ -95,12 +95,13 @@ function getStatusConfig(status: string): {
|
|||
}
|
||||
}
|
||||
|
||||
function getIssueTypeConfig(type: string): {
|
||||
function getIssueTypeConfig(type: string | null | undefined): {
|
||||
label: string;
|
||||
classes: string;
|
||||
icon: typeof Bug;
|
||||
} {
|
||||
switch (type) {
|
||||
const t = String(type ?? "").trim();
|
||||
switch (t) {
|
||||
case "bug":
|
||||
return {
|
||||
label: "Bug",
|
||||
|
|
@ -133,7 +134,7 @@ function getIssueTypeConfig(type: string): {
|
|||
};
|
||||
default:
|
||||
return {
|
||||
label: type.charAt(0).toUpperCase() + type.slice(1),
|
||||
label: t ? t.charAt(0).toUpperCase() + t.slice(1) : "Other",
|
||||
classes: "bg-grayScale-100 text-grayScale-600 border-grayScale-200",
|
||||
icon: HelpCircle,
|
||||
};
|
||||
|
|
@ -173,8 +174,10 @@ function getRelativeTime(dateStr: string): string {
|
|||
return formatDate(dateStr);
|
||||
}
|
||||
|
||||
function formatRoleLabel(role: string): string {
|
||||
return role
|
||||
function formatRoleLabel(role: string | null | undefined): string {
|
||||
const r = String(role ?? "").trim();
|
||||
if (!r) return "—";
|
||||
return r
|
||||
.split("_")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
|
|
@ -221,8 +224,9 @@ export function IssuesPage() {
|
|||
offset: (page - 1) * pageSize,
|
||||
};
|
||||
const res = await getIssues(filters);
|
||||
setIssues(res.data.data.issues);
|
||||
setTotalCount(res.data.data.total_count);
|
||||
const payload = res.data?.data;
|
||||
setIssues(Array.isArray(payload?.issues) ? payload.issues : []);
|
||||
setTotalCount(typeof payload?.total_count === "number" ? payload.total_count : 0);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch issues:", error);
|
||||
setIssues([]);
|
||||
|
|
@ -241,7 +245,7 @@ export function IssuesPage() {
|
|||
setDetailLoading(true);
|
||||
try {
|
||||
const res = await getIssueById(issueId);
|
||||
setSelectedIssue(res.data.data);
|
||||
setSelectedIssue(res.data?.data ?? null);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch issue detail:", error);
|
||||
} finally {
|
||||
|
|
@ -305,16 +309,15 @@ export function IssuesPage() {
|
|||
};
|
||||
|
||||
// Client-side filtering (status, type, search)
|
||||
const filteredIssues = issues.filter((issue) => {
|
||||
const filteredIssues = (Array.isArray(issues) ? issues : []).filter((issue) => {
|
||||
if (statusFilter && issue.status !== statusFilter) return false;
|
||||
if (typeFilter && issue.issue_type !== typeFilter) return false;
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
return (
|
||||
issue.subject.toLowerCase().includes(q) ||
|
||||
issue.description.toLowerCase().includes(q) ||
|
||||
issue.issue_type.toLowerCase().includes(q)
|
||||
);
|
||||
const subject = String(issue.subject ?? "").toLowerCase();
|
||||
const description = String(issue.description ?? "").toLowerCase();
|
||||
const issueType = String(issue.issue_type ?? "").toLowerCase();
|
||||
return subject.includes(q) || description.includes(q) || issueType.includes(q);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
|
@ -537,10 +540,10 @@ export function IssuesPage() {
|
|||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-grayScale-600 truncate">
|
||||
{issue.subject}
|
||||
{issue.subject?.trim() ? issue.subject : "—"}
|
||||
</p>
|
||||
<p className="text-xs text-grayScale-400 truncate mt-0.5">
|
||||
{issue.description}
|
||||
{issue.description?.trim() ? issue.description : "No description"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -572,6 +575,9 @@ export function IssuesPage() {
|
|||
{getStatusConfig(s).label}
|
||||
</option>
|
||||
))}
|
||||
{!STATUSES.includes(issue.status as (typeof STATUSES)[number]) && issue.status ? (
|
||||
<option value={issue.status}>{getStatusConfig(issue.status).label}</option>
|
||||
) : null}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 pointer-events-none opacity-50" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Bell, Loader2, Mail, MailOpen, Megaphone } from "lucide-react"
|
||||
import { Bell, Mail, MailOpen, Megaphone } from "lucide-react"
|
||||
import { Card, CardContent } from "../../components/ui/card"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { Textarea } from "../../components/ui/textarea"
|
||||
import { FileUpload } from "../../components/ui/file-upload"
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { getTeamMembers } from "../../api/team.api"
|
||||
import type { TeamMember } from "../../types/team.types"
|
||||
|
|
@ -282,7 +283,7 @@ export function CreateNotificationPage() {
|
|||
>
|
||||
{sending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
<SpinnerIcon className="mr-2 h-3.5 w-3.5" alt="" />
|
||||
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">
|
||||
{recipientsLoading && (
|
||||
<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…
|
||||
</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 {
|
||||
Plus, Search, Shield, ShieldCheck, ChevronLeft, ChevronRight,
|
||||
AlertCircle, Eye, X, Pencil, Check,
|
||||
Plus,
|
||||
Search,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
AlertCircle,
|
||||
Eye,
|
||||
X,
|
||||
Pencil,
|
||||
Check,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Card, CardContent } from "../../components/ui/card"
|
||||
|
|
@ -12,7 +22,14 @@ import { Textarea } from "../../components/ui/textarea"
|
|||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||
} from "../../components/ui/dialog"
|
||||
import { getRoles, getRoleDetail, getAllPermissions, setRolePermissions, updateRole } from "../../api/rbac.api"
|
||||
import {
|
||||
getRoles,
|
||||
getRoleDetail,
|
||||
getAllPermissions,
|
||||
setRolePermissions,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
} from "../../api/rbac.api"
|
||||
import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { toast } from "sonner"
|
||||
|
|
@ -36,6 +53,11 @@ export function RolesListPage() {
|
|||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
|
||||
// Delete modal state
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [roleToDelete, setRoleToDelete] = useState<Role | null>(null)
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
|
||||
// Role info editing state
|
||||
const [editingRole, setEditingRole] = useState(false)
|
||||
const [editName, setEditName] = useState("")
|
||||
|
|
@ -59,27 +81,28 @@ export function RolesListPage() {
|
|||
return () => clearTimeout(timer)
|
||||
}, [query])
|
||||
|
||||
const fetchRoles = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await getRoles({
|
||||
query: debouncedQuery || undefined,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
})
|
||||
setRoles(res.data.data.roles ?? [])
|
||||
setTotal(res.data.data.total ?? 0)
|
||||
} catch {
|
||||
setError("Failed to load roles.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [debouncedQuery, page, pageSize])
|
||||
|
||||
// Fetch roles
|
||||
useEffect(() => {
|
||||
const fetchRoles = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await getRoles({
|
||||
query: debouncedQuery || undefined,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
})
|
||||
setRoles(res.data.data.roles ?? [])
|
||||
setTotal(res.data.data.total ?? 0)
|
||||
} catch {
|
||||
setError("Failed to load roles.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchRoles()
|
||||
}, [debouncedQuery, page, pageSize])
|
||||
}, [fetchRoles])
|
||||
|
||||
// Open role detail
|
||||
const handleViewRole = async (roleId: number) => {
|
||||
|
|
@ -97,6 +120,45 @@ export function RolesListPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleDeleteRoleClick = (role: Role) => {
|
||||
setRoleToDelete(role)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleCancelDeleteRole = () => {
|
||||
setDeleteDialogOpen(false)
|
||||
setRoleToDelete(null)
|
||||
}
|
||||
|
||||
const handleConfirmDeleteRole = async () => {
|
||||
if (!roleToDelete) return
|
||||
setDeleteLoading(true)
|
||||
try {
|
||||
const res = await deleteRole(roleToDelete.id)
|
||||
toast.success(res.data.message ?? "Role deleted successfully")
|
||||
|
||||
// Close dialogs if the deleted role is currently opened.
|
||||
if (selectedRole?.id === roleToDelete.id) {
|
||||
setDetailOpen(false)
|
||||
setSelectedRole(null)
|
||||
setEditingPermissions(false)
|
||||
setEditingRole(false)
|
||||
setPermSearch("")
|
||||
}
|
||||
|
||||
setRoleToDelete(null)
|
||||
setDeleteDialogOpen(false)
|
||||
await fetchRoles()
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
"Failed to delete role."
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setDeleteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Enter role info edit mode
|
||||
const handleEditRole = () => {
|
||||
if (!selectedRole) return
|
||||
|
|
@ -302,7 +364,7 @@ export function RolesListPage() {
|
|||
{roles.map((role) => (
|
||||
<Card
|
||||
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
|
||||
className={cn(
|
||||
|
|
@ -312,7 +374,7 @@ export function RolesListPage() {
|
|||
: "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-center gap-2.5">
|
||||
<div
|
||||
|
|
@ -330,32 +392,63 @@ export function RolesListPage() {
|
|||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-grayScale-700">{role.name}</h3>
|
||||
<p className="mt-0.5 text-xs text-grayScale-400 line-clamp-1">
|
||||
{role.description}
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-grayScale-700">
|
||||
{role.name}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-grayScale-500 line-clamp-2">
|
||||
{role.description?.trim() || "No description provided for this role."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{role.is_system && (
|
||||
<Badge variant="warning" className="shrink-0 text-[10px]">
|
||||
System
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant={role.is_system ? "warning" : "outline"}
|
||||
className="shrink-0 text-[10px]"
|
||||
>
|
||||
{role.is_system ? "System" : "Custom"}
|
||||
</Badge>
|
||||
</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">
|
||||
Created {new Date(role.created_at).toLocaleDateString()}
|
||||
Open details to view permissions
|
||||
</span>
|
||||
<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 className="flex items-center gap-2">
|
||||
{!role.is_system && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleDeleteRoleClick(role)}
|
||||
disabled={deleteLoading}
|
||||
aria-label={`Delete role ${role.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -689,6 +782,55 @@ export function RolesListPage() {
|
|||
)}
|
||||
</DialogContent>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export interface GetCoursesResponse {
|
|||
|
||||
export interface CreateCourseRequest {
|
||||
category_id: number
|
||||
sub_category_id?: number | null
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
|
@ -56,6 +57,268 @@ export interface UpdateCourseRequest {
|
|||
is_active?: boolean
|
||||
}
|
||||
|
||||
/** Row from GET /programs (e.g. Beginner / Intermediate program buckets) */
|
||||
export interface LearningProgramListItem {
|
||||
id: number
|
||||
name: string
|
||||
description?: string | null
|
||||
thumbnail?: string | null
|
||||
sort_order: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface UpdateLearningProgramRequest {
|
||||
name: string
|
||||
description: string
|
||||
thumbnail: string
|
||||
}
|
||||
|
||||
export interface CreateLearningProgramRequest {
|
||||
name: string
|
||||
description: string
|
||||
thumbnail: string
|
||||
}
|
||||
|
||||
export interface CreateLearningProgramResponse {
|
||||
message: string
|
||||
data: LearningProgramListItem
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
export interface GetLearningProgramsResponse {
|
||||
message: string
|
||||
data: {
|
||||
programs: LearningProgramListItem[]
|
||||
total_count: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
/** Row from GET /programs/:program_id/courses */
|
||||
export interface ProgramCourseListItem {
|
||||
id: number
|
||||
program_id: number
|
||||
name: string
|
||||
description: string
|
||||
sort_order: number
|
||||
created_at: string
|
||||
thumbnail?: string | null
|
||||
/** Some list endpoints may expose the image as `thumbnail_url` instead. */
|
||||
thumbnail_url?: string | null
|
||||
/** GET /programs/:id/courses aggregates. */
|
||||
module_count?: number
|
||||
lesson_count?: number
|
||||
practice_count?: number
|
||||
/** Legacy aggregate field names; prefer module_count, lesson_count, practice_count. */
|
||||
modules_count?: number
|
||||
videos_count?: number
|
||||
practices_count?: number
|
||||
}
|
||||
|
||||
/** Body for PUT /courses/:id (program-linked Learn English courses). */
|
||||
export interface UpdateTopLevelCourseRequest {
|
||||
name: string
|
||||
description: string
|
||||
thumbnail: string
|
||||
}
|
||||
|
||||
/** Body for POST /programs/:program_id/courses */
|
||||
export interface CreateProgramCourseRequest {
|
||||
name: string
|
||||
description: string
|
||||
thumbnail: string
|
||||
}
|
||||
|
||||
export interface CreateProgramCourseResponse {
|
||||
message: string
|
||||
data: ProgramCourseListItem
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
export interface GetProgramCoursesResponse {
|
||||
message: string
|
||||
data: {
|
||||
total_count: number
|
||||
limit: number
|
||||
offset: number
|
||||
courses: ProgramCourseListItem[]
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
/** Row from GET /courses/:courseId/modules (Learn English track). */
|
||||
export interface TopLevelCourseModuleItem {
|
||||
id: number
|
||||
program_id: number
|
||||
course_id: number
|
||||
name: string
|
||||
description: string
|
||||
icon?: string | null
|
||||
sort_order: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface GetTopLevelCourseModulesResponse {
|
||||
message: string
|
||||
data: {
|
||||
limit: number
|
||||
offset: number
|
||||
modules: TopLevelCourseModuleItem[]
|
||||
total_count: number
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
/** Body for PUT /modules/:id (Learn English top-level modules). */
|
||||
export interface UpdateTopLevelCourseModuleRequest {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
/** Body for POST /courses/:courseId/modules */
|
||||
export interface CreateTopLevelCourseModuleRequest {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface CreateTopLevelCourseModuleResponse {
|
||||
message: string
|
||||
data: TopLevelCourseModuleItem
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
/** Row from GET /modules/:moduleId/lessons (Learn English top-level module lessons). */
|
||||
export interface TopLevelModuleLessonItem {
|
||||
id: number
|
||||
module_id: number
|
||||
title: string
|
||||
video_url: string
|
||||
thumbnail: string
|
||||
description: string
|
||||
sort_order: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface GetTopLevelModuleLessonsResponse {
|
||||
message: string
|
||||
data: {
|
||||
total_count: number
|
||||
limit: number
|
||||
offset: number
|
||||
lessons: TopLevelModuleLessonItem[]
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
/** Practice returned by GET /courses|modules|lessons/.../practices (Learn English parent-linked practice). */
|
||||
export interface ParentContextPractice {
|
||||
id: number
|
||||
parent_kind: string
|
||||
parent_id: number
|
||||
title: string
|
||||
story_description: string
|
||||
story_image: string
|
||||
question_set_id: number
|
||||
quick_tips: string
|
||||
persona_id?: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface GetPracticesByParentContextResponse {
|
||||
message: string
|
||||
data: {
|
||||
offset: number
|
||||
limit: number
|
||||
practices: ParentContextPractice[]
|
||||
total_count: number
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
export type PracticeParentKind = "COURSE" | "MODULE" | "LESSON"
|
||||
|
||||
/** POST /practices — create practice linked to a course, module, or lesson (Learn English). */
|
||||
export interface CreateParentLinkedPracticeRequest {
|
||||
parent_kind: PracticeParentKind
|
||||
parent_id: number
|
||||
title: string
|
||||
story_description: string
|
||||
story_image: string
|
||||
question_set_id: number
|
||||
quick_tips: string
|
||||
persona_id?: number
|
||||
}
|
||||
|
||||
export interface CreateParentLinkedPracticeResponse {
|
||||
message: string
|
||||
data: ParentContextPractice
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
/** Body for PUT /practices/:id (Learn English parent-linked practice). */
|
||||
export interface UpdateParentLinkedPracticeRequest {
|
||||
title: string
|
||||
story_description: string
|
||||
story_image: string
|
||||
question_set_id: number
|
||||
quick_tips: string
|
||||
persona_id?: number | null
|
||||
}
|
||||
|
||||
export interface UpdateParentLinkedPracticeResponse {
|
||||
message: string
|
||||
data: ParentContextPractice
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
/** Body for PUT /lessons/:id (Learn English top-level module lessons). */
|
||||
export interface UpdateTopLevelModuleLessonRequest {
|
||||
title: string
|
||||
video_url: string
|
||||
thumbnail: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/** Body for POST /modules/:moduleId/lessons. */
|
||||
export interface CreateTopLevelModuleLessonRequest {
|
||||
title: string
|
||||
video_url: string
|
||||
thumbnail: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface CreateTopLevelModuleLessonResponse {
|
||||
message: string
|
||||
data: TopLevelModuleLessonItem
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Legacy Types (deprecated - using SubCourse hierarchy now)
|
||||
// Keeping for backward compatibility with existing API endpoints
|
||||
|
|
@ -172,7 +435,13 @@ export interface GetModulesResponse {
|
|||
export interface CreateModuleRequest {
|
||||
level_id: number
|
||||
title: string
|
||||
content: string
|
||||
/** Legacy field kept for backward compatibility. */
|
||||
content?: string
|
||||
/** Preferred field for module detail text. */
|
||||
description?: string
|
||||
icon_url?: string
|
||||
display_order?: number
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
/** @deprecated Use UpdateSubCourseRequest instead */
|
||||
|
|
@ -192,6 +461,8 @@ export interface UpdateModuleStatusRequest {
|
|||
export interface SubCourse {
|
||||
id: number
|
||||
course_id: number
|
||||
/** Present when derived from course hierarchy rows (levels → modules → sub-modules). */
|
||||
level_id?: number
|
||||
module_id?: number
|
||||
title: string
|
||||
description: string
|
||||
|
|
@ -701,6 +972,72 @@ export interface HumanLanguageLesson {
|
|||
practices: LearningPathPractice[]
|
||||
}
|
||||
|
||||
export interface SubModuleLessonDetail {
|
||||
id: number
|
||||
sub_module_id: number
|
||||
display_order: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
title: string
|
||||
description?: string | null
|
||||
thumbnail?: string | null
|
||||
teaching_text?: string | null
|
||||
teaching_image_url?: string | null
|
||||
teaching_audio_url?: string | null
|
||||
teaching_video_url?: string | null
|
||||
}
|
||||
|
||||
export interface SubModuleLesson {
|
||||
id: number
|
||||
sub_module_id: number
|
||||
display_order: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
title: string
|
||||
description?: string | null
|
||||
thumbnail?: string | null
|
||||
teaching_text?: string | null
|
||||
teaching_image_url?: string | null
|
||||
teaching_audio_url?: string | null
|
||||
teaching_video_url?: string | null
|
||||
}
|
||||
|
||||
export interface GetSubModuleLessonDetailResponse {
|
||||
message: string
|
||||
data: SubModuleLessonDetail
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface UpdateSubModuleLessonRequest {
|
||||
title: string
|
||||
description?: string | null
|
||||
thumbnail?: string | null
|
||||
teaching_text?: string | null
|
||||
teaching_image_url?: string | null
|
||||
teaching_audio_url?: string | null
|
||||
teaching_video_url?: string | null
|
||||
display_order: number
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface UpdateSubModuleLessonResponse {
|
||||
message: string
|
||||
data: SubModuleLessonDetail
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface GetSubModuleLessonsResponse {
|
||||
message: string
|
||||
data: SubModuleLesson[]
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface GetHumanLanguageLessonsResponse {
|
||||
message: string
|
||||
data: {
|
||||
|
|
@ -714,10 +1051,209 @@ export interface GetHumanLanguageLessonsResponse {
|
|||
metadata: unknown
|
||||
}
|
||||
|
||||
/** Row from GET /course-management/human-language/sub-categories */
|
||||
export interface HumanLanguageSubCategoryListItem {
|
||||
id: number
|
||||
category_id: number
|
||||
category_name: string
|
||||
name: string
|
||||
description?: string | null
|
||||
display_order: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
/** Present on some payloads; ignore if unused. */
|
||||
total_count?: number
|
||||
}
|
||||
|
||||
export interface GetHumanLanguageSubCategoriesResponse {
|
||||
message: string
|
||||
data: {
|
||||
sub_categories: HumanLanguageSubCategoryListItem[]
|
||||
total_count: number
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
/** Row from GET /course-management/categories/:categoryId/sub-categories */
|
||||
export interface CategorySubCategoryListItem {
|
||||
id: number
|
||||
category_id: number
|
||||
category_name: string
|
||||
name: string
|
||||
description?: string | null
|
||||
display_order: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
/** Sometimes echoed per row by the API; safe to ignore. */
|
||||
total_count?: number
|
||||
}
|
||||
|
||||
export interface GetCategorySubCategoriesResponse {
|
||||
message: string
|
||||
data: {
|
||||
sub_categories: CategorySubCategoryListItem[]
|
||||
total_count: number
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
/** Row from GET /course-management/sub-categories/:subCategoryId/courses */
|
||||
export interface SubCategoryCourseListItem {
|
||||
id: number
|
||||
category_id: number
|
||||
sub_category_id: number
|
||||
title: string
|
||||
description?: string | null
|
||||
thumbnail?: string | null
|
||||
intro_video_url?: string | null
|
||||
is_active: boolean
|
||||
total_count?: number
|
||||
}
|
||||
|
||||
export interface GetSubCategoryCoursesResponse {
|
||||
message: string
|
||||
data: {
|
||||
courses: SubCategoryCourseListItem[]
|
||||
total_count: number
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
/** Row from GET /course-management/courses/:courseId/levels or GET /course-management/levels */
|
||||
export interface CourseLevelRow {
|
||||
id: number
|
||||
course_id: number
|
||||
cefr_level: string
|
||||
display_order: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
title: string
|
||||
description?: string | null
|
||||
thumbnail?: string | null
|
||||
total_count?: number
|
||||
}
|
||||
|
||||
export interface GetCourseLevelsForCourseResponse {
|
||||
message: string
|
||||
data: {
|
||||
levels: CourseLevelRow[]
|
||||
total_count: number
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface GetCourseLevelsAllResponse {
|
||||
message: string
|
||||
data: {
|
||||
levels: CourseLevelRow[]
|
||||
total_count: number
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface GetCourseLevelByIdResponse {
|
||||
message: string
|
||||
data: CourseLevelRow
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
/** Row from GET /course-management/modules/:moduleId/sub-modules */
|
||||
export interface CourseSubModuleListItem {
|
||||
id: number
|
||||
module_id: number
|
||||
title: string
|
||||
description?: string | null
|
||||
display_order: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
legacy_sub_course_id?: number | null
|
||||
thumbnail?: string | null
|
||||
tips?: string | null
|
||||
total_count?: number
|
||||
}
|
||||
|
||||
export interface GetSubModulesByModuleResponse {
|
||||
message: string
|
||||
data: {
|
||||
sub_modules: CourseSubModuleListItem[]
|
||||
total_count: number
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
/** Row from GET /course-management/human-language/hierarchy */
|
||||
export interface HumanLanguageHierarchyFlatRow {
|
||||
category_id: number
|
||||
category_name: string
|
||||
sub_category_id?: number | null
|
||||
sub_category_name?: string | null
|
||||
course_id?: number | null
|
||||
course_title?: string | null
|
||||
}
|
||||
|
||||
export interface GetHumanLanguageHierarchyFlatResponse {
|
||||
message: string
|
||||
data: HumanLanguageHierarchyFlatRow[]
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
/** Row from GET /course-management/courses/:courseId/hierarchy */
|
||||
export interface CourseHierarchyRow {
|
||||
course_id: number
|
||||
course_title: string
|
||||
level_id?: number | null
|
||||
cefr_level?: string | null
|
||||
level_title?: string | null
|
||||
level_description?: string | null
|
||||
level_thumbnail?: string | null
|
||||
module_id?: number | null
|
||||
module_title?: string | null
|
||||
module_icon_url?: string | null
|
||||
sub_module_id?: number | null
|
||||
sub_module_title?: string | null
|
||||
sub_module_description?: string | null
|
||||
sub_module_thumbnail?: string | null
|
||||
sub_module_tips?: string | null
|
||||
sub_module_display_order?: number | null
|
||||
}
|
||||
|
||||
export interface GetCourseHierarchyResponse {
|
||||
message: string
|
||||
data: CourseHierarchyRow[]
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface HumanLanguageSubModule {
|
||||
id: number
|
||||
title: string
|
||||
videos: LearningPathVideo[]
|
||||
lessons?: {
|
||||
id: number
|
||||
question_set_id: number
|
||||
title: string
|
||||
status: string
|
||||
question_count: number
|
||||
display_order: number
|
||||
intro_video_url?: string | null
|
||||
}[]
|
||||
practices: LearningPathPractice[]
|
||||
}
|
||||
|
||||
|
|
@ -728,6 +1264,7 @@ export interface HumanLanguageModule {
|
|||
}
|
||||
|
||||
export interface HumanLanguageLevelTree {
|
||||
level_id?: number
|
||||
level: string
|
||||
modules: HumanLanguageModule[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,14 @@ export interface CreateRoleResponse {
|
|||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface DeleteRoleResponse {
|
||||
message: string
|
||||
success: boolean
|
||||
status_code: number
|
||||
// Some backends may include extra fields; keep it optional for compatibility.
|
||||
metadata?: unknown
|
||||
}
|
||||
|
||||
export interface SetRolePermissionsRequest {
|
||||
permission_ids: number[]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user