Compare commits
No commits in common. "aa998e55990ba5612621a3b53b02c19dccae465f" and "7308d9bbcdbdd675f688da6fbc4e1511359ec844" have entirely different histories.
aa998e5599
...
7308d9bbcd
|
|
@ -1,479 +0,0 @@
|
||||||
# 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,69 +44,18 @@ import type {
|
||||||
GetQuestionsResponse,
|
GetQuestionsResponse,
|
||||||
CreateVimeoVideoRequest,
|
CreateVimeoVideoRequest,
|
||||||
CreateCourseCategoryRequest,
|
CreateCourseCategoryRequest,
|
||||||
GetCategorySubCategoriesResponse,
|
|
||||||
GetSubCategoryCoursesResponse,
|
|
||||||
GetSubCoursePrerequisitesResponse,
|
GetSubCoursePrerequisitesResponse,
|
||||||
AddSubCoursePrerequisiteRequest,
|
AddSubCoursePrerequisiteRequest,
|
||||||
GetLearningPathResponse,
|
GetLearningPathResponse,
|
||||||
GetHumanLanguageLessonsResponse,
|
GetHumanLanguageLessonsResponse,
|
||||||
GetHumanLanguageHierarchyResponse,
|
GetHumanLanguageHierarchyResponse,
|
||||||
GetCourseHierarchyResponse,
|
|
||||||
CreateHumanLanguageLessonRequest,
|
CreateHumanLanguageLessonRequest,
|
||||||
GetSubModuleLessonsResponse,
|
|
||||||
GetSubModuleLessonDetailResponse,
|
|
||||||
UpdateSubModuleLessonRequest,
|
|
||||||
UpdateSubModuleLessonResponse,
|
|
||||||
GetCourseLevelsForCourseResponse,
|
|
||||||
GetSubModulesByModuleResponse,
|
|
||||||
SubCourse,
|
|
||||||
GetSubCourseEntryAssessmentResponse,
|
GetSubCourseEntryAssessmentResponse,
|
||||||
ReorderItem,
|
ReorderItem,
|
||||||
GetRatingsResponse,
|
GetRatingsResponse,
|
||||||
GetRatingsParams,
|
GetRatingsParams,
|
||||||
GetVimeoSampleResponse,
|
GetVimeoSampleResponse,
|
||||||
CreateCourseVideoRequest,
|
CreateCourseVideoRequest,
|
||||||
GetLearningProgramsResponse,
|
|
||||||
UpdateLearningProgramRequest,
|
|
||||||
CreateLearningProgramRequest,
|
|
||||||
CreateLearningProgramResponse,
|
|
||||||
GetProgramCoursesResponse,
|
|
||||||
GetTopLevelCourseModulesResponse,
|
|
||||||
UpdateTopLevelCourseRequest,
|
|
||||||
UpdateTopLevelCourseModuleRequest,
|
|
||||||
CreateTopLevelCourseModuleRequest,
|
|
||||||
CreateTopLevelCourseModuleResponse,
|
|
||||||
CreateProgramCourseRequest,
|
|
||||||
CreateProgramCourseResponse,
|
|
||||||
CreateExamPrepCatalogCourseRequest,
|
|
||||||
CreateExamPrepCatalogCourseResponse,
|
|
||||||
GetExamPrepCatalogCoursesResponse,
|
|
||||||
UpdateExamPrepCatalogCourseRequest,
|
|
||||||
UpdateExamPrepCatalogCourseResponse,
|
|
||||||
CreateExamPrepCatalogUnitRequest,
|
|
||||||
CreateExamPrepCatalogUnitResponse,
|
|
||||||
UpdateExamPrepCatalogUnitRequest,
|
|
||||||
UpdateExamPrepCatalogUnitResponse,
|
|
||||||
GetExamPrepCatalogUnitsResponse,
|
|
||||||
CreateExamPrepUnitModuleRequest,
|
|
||||||
CreateExamPrepUnitModuleResponse,
|
|
||||||
UpdateExamPrepUnitModuleRequest,
|
|
||||||
UpdateExamPrepUnitModuleResponse,
|
|
||||||
GetExamPrepUnitModulesResponse,
|
|
||||||
CreateExamPrepModuleLessonRequest,
|
|
||||||
CreateExamPrepModuleLessonResponse,
|
|
||||||
UpdateExamPrepModuleLessonRequest,
|
|
||||||
UpdateExamPrepModuleLessonResponse,
|
|
||||||
GetExamPrepModuleLessonsResponse,
|
|
||||||
GetTopLevelModuleLessonsResponse,
|
|
||||||
GetPracticesByParentContextResponse,
|
|
||||||
CreateParentLinkedPracticeRequest,
|
|
||||||
CreateParentLinkedPracticeResponse,
|
|
||||||
UpdateParentLinkedPracticeRequest,
|
|
||||||
UpdateParentLinkedPracticeResponse,
|
|
||||||
UpdateTopLevelModuleLessonRequest,
|
|
||||||
CreateTopLevelModuleLessonRequest,
|
|
||||||
CreateTopLevelModuleLessonResponse,
|
|
||||||
} from "../types/course.types"
|
} from "../types/course.types"
|
||||||
|
|
||||||
type UnifiedHierarchyRow = {
|
type UnifiedHierarchyRow = {
|
||||||
|
|
@ -161,35 +110,6 @@ export const createCourseCategory = (data: CreateCourseCategoryRequest) =>
|
||||||
? http.post("/course-management/sub-categories", { category_id: data.parent_id, name: data.name })
|
? http.post("/course-management/sub-categories", { category_id: data.parent_id, name: data.name })
|
||||||
: http.post("/course-management/categories", { name: data.name })
|
: http.post("/course-management/categories", { name: data.name })
|
||||||
|
|
||||||
export const deleteCourseCategory = (categoryId: number) =>
|
|
||||||
http.delete(`/course-management/categories/${categoryId}`)
|
|
||||||
|
|
||||||
export const 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) =>
|
export const getCoursesByCategory = (categoryId: number) =>
|
||||||
http.get("/course-management/hierarchy").then((res) => {
|
http.get("/course-management/hierarchy").then((res) => {
|
||||||
const rows: UnifiedHierarchyRow[] = res.data?.data ?? []
|
const rows: UnifiedHierarchyRow[] = res.data?.data ?? []
|
||||||
|
|
@ -228,13 +148,9 @@ export const updateCourse = (courseId: number, data: UpdateCourseRequest) =>
|
||||||
http.put(`/course-management/courses/${courseId}`, data)
|
http.put(`/course-management/courses/${courseId}`, data)
|
||||||
|
|
||||||
// Sub-Module APIs (Unified Hierarchy)
|
// Sub-Module APIs (Unified Hierarchy)
|
||||||
export const getCourseHierarchyByCourseId = (courseId: number) =>
|
|
||||||
http.get<GetCourseHierarchyResponse>(`/course-management/courses/${courseId}/hierarchy`)
|
|
||||||
|
|
||||||
export const getSubModulesByCourse = (courseId: number) =>
|
export const getSubModulesByCourse = (courseId: number) =>
|
||||||
http.get(`/course-management/courses/${courseId}/hierarchy`).then((res) => {
|
http.get(`/course-management/courses/${courseId}/hierarchy`).then((res) => {
|
||||||
const raw = res.data?.data
|
const rows: CourseHierarchyRow[] = 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 }>()
|
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) => {
|
rows.forEach((r, idx) => {
|
||||||
if (!r.sub_module_id) return
|
if (!r.sub_module_id) return
|
||||||
|
|
@ -309,27 +225,6 @@ export const deleteSubModule = (subModuleId: number) =>
|
||||||
export const getVideosBySubModule = (subModuleId: number) =>
|
export const getVideosBySubModule = (subModuleId: number) =>
|
||||||
http.get<GetSubCourseVideosResponse>(`/course-management/sub-modules/${subModuleId}/videos`)
|
http.get<GetSubCourseVideosResponse>(`/course-management/sub-modules/${subModuleId}/videos`)
|
||||||
|
|
||||||
export const getLessonsBySubModule = (subModuleId: number, options?: { includeInactive?: boolean }) =>
|
|
||||||
http.get<GetSubModuleLessonsResponse>(`/course-management/sub-modules/${subModuleId}/lessons`, {
|
|
||||||
params: { include_inactive: options?.includeInactive ?? true },
|
|
||||||
})
|
|
||||||
|
|
||||||
export const getSubModuleLessonById = (
|
|
||||||
lessonId: number,
|
|
||||||
options?: { cacheBust?: boolean },
|
|
||||||
) =>
|
|
||||||
http.get<GetSubModuleLessonDetailResponse>(`/course-management/sub-module-lessons/${lessonId}`, {
|
|
||||||
params: options?.cacheBust ? { _t: Date.now() } : undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const updateSubModuleLesson = (lessonId: number, data: UpdateSubModuleLessonRequest) =>
|
|
||||||
http.put<UpdateSubModuleLessonResponse>(`/course-management/sub-module-lessons/${lessonId}`, data)
|
|
||||||
|
|
||||||
export const softDeleteSubModuleLesson = (lessonId: number) =>
|
|
||||||
http.put<UpdateSubModuleLessonResponse>(`/course-management/sub-module-lessons/${lessonId}`, {
|
|
||||||
is_active: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const createSubCourseVideo = (data: CreateSubCourseVideoRequest) =>
|
export const createSubCourseVideo = (data: CreateSubCourseVideoRequest) =>
|
||||||
http.post("/course-management/sub-module-videos", {
|
http.post("/course-management/sub-module-videos", {
|
||||||
sub_module_id: data.sub_module_id ?? data.sub_course_id,
|
sub_module_id: data.sub_module_id ?? data.sub_course_id,
|
||||||
|
|
@ -450,248 +345,6 @@ export const updatePracticeQuestion = (questionId: number, data: UpdatePracticeQ
|
||||||
export const deletePracticeQuestion = (questionId: number) =>
|
export const deletePracticeQuestion = (questionId: number) =>
|
||||||
http.delete(`/questions/${questionId}`)
|
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)
|
|
||||||
|
|
||||||
/** English proficiency catalog course — POST /exam-prep/catalog-courses */
|
|
||||||
export const createExamPrepCatalogCourse = (
|
|
||||||
data: CreateExamPrepCatalogCourseRequest,
|
|
||||||
) => http.post<CreateExamPrepCatalogCourseResponse>("/exam-prep/catalog-courses", data)
|
|
||||||
|
|
||||||
/** English proficiency catalog courses — GET /exam-prep/catalog-courses */
|
|
||||||
export const getExamPrepCatalogCourses = (params?: { limit?: number; offset?: number }) =>
|
|
||||||
http.get<GetExamPrepCatalogCoursesResponse>("/exam-prep/catalog-courses", { params })
|
|
||||||
|
|
||||||
/** English proficiency catalog course — PUT /exam-prep/catalog-courses/:catalogCourseId */
|
|
||||||
export const updateExamPrepCatalogCourse = (
|
|
||||||
catalogCourseId: number,
|
|
||||||
data: UpdateExamPrepCatalogCourseRequest,
|
|
||||||
) =>
|
|
||||||
http.put<UpdateExamPrepCatalogCourseResponse>(
|
|
||||||
`/exam-prep/catalog-courses/${catalogCourseId}`,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** English proficiency catalog course — DELETE /exam-prep/catalog-courses/:catalogCourseId */
|
|
||||||
export const deleteExamPrepCatalogCourse = (catalogCourseId: number) =>
|
|
||||||
http.delete(`/exam-prep/catalog-courses/${catalogCourseId}`)
|
|
||||||
|
|
||||||
/** English proficiency catalog unit — POST /exam-prep/catalog-courses/:catalogCourseId/units */
|
|
||||||
export const createExamPrepCatalogUnit = (
|
|
||||||
catalogCourseId: number,
|
|
||||||
data: CreateExamPrepCatalogUnitRequest,
|
|
||||||
) =>
|
|
||||||
http.post<CreateExamPrepCatalogUnitResponse>(
|
|
||||||
`/exam-prep/catalog-courses/${catalogCourseId}/units`,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** English proficiency catalog units — GET /exam-prep/catalog-courses/:catalogCourseId/units */
|
|
||||||
export const getExamPrepCatalogUnits = (
|
|
||||||
catalogCourseId: number,
|
|
||||||
params?: { limit?: number; offset?: number },
|
|
||||||
) =>
|
|
||||||
http.get<GetExamPrepCatalogUnitsResponse>(
|
|
||||||
`/exam-prep/catalog-courses/${catalogCourseId}/units`,
|
|
||||||
{ params },
|
|
||||||
)
|
|
||||||
|
|
||||||
/** English proficiency unit — PUT /exam-prep/units/:unitId */
|
|
||||||
export const updateExamPrepCatalogUnit = (
|
|
||||||
unitId: number,
|
|
||||||
data: UpdateExamPrepCatalogUnitRequest,
|
|
||||||
) => http.put<UpdateExamPrepCatalogUnitResponse>(`/exam-prep/units/${unitId}`, data)
|
|
||||||
|
|
||||||
/** English proficiency unit — DELETE /exam-prep/units/:unitId */
|
|
||||||
export const deleteExamPrepCatalogUnit = (unitId: number) =>
|
|
||||||
http.delete(`/exam-prep/units/${unitId}`)
|
|
||||||
|
|
||||||
/** English proficiency unit modules — POST /exam-prep/units/:unitId/modules */
|
|
||||||
export const createExamPrepUnitModule = (
|
|
||||||
unitId: number,
|
|
||||||
data: CreateExamPrepUnitModuleRequest,
|
|
||||||
) =>
|
|
||||||
http.post<CreateExamPrepUnitModuleResponse>(
|
|
||||||
`/exam-prep/units/${unitId}/modules`,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** English proficiency unit modules — GET /exam-prep/units/:unitId/modules */
|
|
||||||
export const getExamPrepUnitModules = (
|
|
||||||
unitId: number,
|
|
||||||
params?: { limit?: number; offset?: number },
|
|
||||||
) =>
|
|
||||||
http.get<GetExamPrepUnitModulesResponse>(`/exam-prep/units/${unitId}/modules`, {
|
|
||||||
params,
|
|
||||||
})
|
|
||||||
|
|
||||||
/** English proficiency module — PUT /exam-prep/modules/:moduleId */
|
|
||||||
export const updateExamPrepUnitModule = (
|
|
||||||
moduleId: number,
|
|
||||||
data: UpdateExamPrepUnitModuleRequest,
|
|
||||||
) =>
|
|
||||||
http.put<UpdateExamPrepUnitModuleResponse>(
|
|
||||||
`/exam-prep/modules/${moduleId}`,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** English proficiency module — DELETE /exam-prep/modules/:moduleId */
|
|
||||||
export const deleteExamPrepUnitModule = (moduleId: number) =>
|
|
||||||
http.delete(`/exam-prep/modules/${moduleId}`)
|
|
||||||
|
|
||||||
/** English proficiency module lessons — GET /exam-prep/modules/:moduleId/lessons */
|
|
||||||
export const getExamPrepModuleLessons = (
|
|
||||||
moduleId: number,
|
|
||||||
params?: { limit?: number; offset?: number },
|
|
||||||
) =>
|
|
||||||
http.get<GetExamPrepModuleLessonsResponse>(
|
|
||||||
`/exam-prep/modules/${moduleId}/lessons`,
|
|
||||||
{
|
|
||||||
params,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
/** English proficiency module lesson — POST /exam-prep/modules/:moduleId/lessons */
|
|
||||||
export const createExamPrepModuleLesson = (
|
|
||||||
moduleId: number,
|
|
||||||
data: CreateExamPrepModuleLessonRequest,
|
|
||||||
) =>
|
|
||||||
http.post<CreateExamPrepModuleLessonResponse>(
|
|
||||||
`/exam-prep/modules/${moduleId}/lessons`,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** English proficiency lesson — PUT /exam-prep/lessons/:lessonId */
|
|
||||||
export const updateExamPrepModuleLesson = (
|
|
||||||
lessonId: number,
|
|
||||||
data: UpdateExamPrepModuleLessonRequest,
|
|
||||||
) =>
|
|
||||||
http.put<UpdateExamPrepModuleLessonResponse>(
|
|
||||||
`/exam-prep/lessons/${lessonId}`,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** English proficiency lesson — DELETE /exam-prep/lessons/:lessonId */
|
|
||||||
export const deleteExamPrepModuleLesson = (lessonId: number) =>
|
|
||||||
http.delete(`/exam-prep/lessons/${lessonId}`)
|
|
||||||
|
|
||||||
/** 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)
|
// Legacy APIs (deprecated - using SubCourse hierarchy now)
|
||||||
// Keeping for backward compatibility
|
// Keeping for backward compatibility
|
||||||
|
|
@ -730,74 +383,6 @@ export const deleteLevel = (levelId: number) =>
|
||||||
export const getModulesByLevel = (levelId: number) =>
|
export const getModulesByLevel = (levelId: number) =>
|
||||||
http.get<GetModulesResponse>(`/course-management/levels/${levelId}/modules`)
|
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) =>
|
export const createModule = (data: CreateModuleRequest) =>
|
||||||
http.post("/course-management/modules", data)
|
http.post("/course-management/modules", data)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,16 +25,6 @@ export interface ResolveFileUrlResponse {
|
||||||
success?: boolean
|
success?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RefreshFileUrlResponse {
|
|
||||||
message: string
|
|
||||||
data?: {
|
|
||||||
object_key?: string
|
|
||||||
url?: string
|
|
||||||
expires_in?: number
|
|
||||||
}
|
|
||||||
success?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UploadMediaOptions {
|
export interface UploadMediaOptions {
|
||||||
title?: string
|
title?: string
|
||||||
description?: string
|
description?: string
|
||||||
|
|
@ -44,36 +34,6 @@ export interface UploadMediaFromUrlPayload extends UploadMediaOptions {
|
||||||
sourceUrl: string
|
sourceUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const GOOGLE_DRIVE_HOSTS = new Set([
|
|
||||||
"drive.google.com",
|
|
||||||
"www.drive.google.com",
|
|
||||||
])
|
|
||||||
|
|
||||||
const getGoogleDriveFileId = (rawUrl: string): string | null => {
|
|
||||||
try {
|
|
||||||
const url = new URL(rawUrl.trim())
|
|
||||||
if (!GOOGLE_DRIVE_HOSTS.has(url.hostname.toLowerCase())) return null
|
|
||||||
const fromQuery = url.searchParams.get("id")?.trim()
|
|
||||||
if (fromQuery) return fromQuery
|
|
||||||
const fileMatch = url.pathname.match(/\/file\/d\/([^/]+)/i)
|
|
||||||
return fileMatch?.[1]?.trim() || null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeSourceUrlForUpload = (
|
|
||||||
mediaType: UploadMediaType,
|
|
||||||
sourceUrl: string,
|
|
||||||
): string => {
|
|
||||||
const trimmed = sourceUrl.trim()
|
|
||||||
if (mediaType !== "image") return trimmed
|
|
||||||
const fileId = getGoogleDriveFileId(trimmed)
|
|
||||||
if (!fileId) return trimmed
|
|
||||||
// Use Drive thumbnail endpoint so backend receives actual image bytes, not HTML viewer.
|
|
||||||
return `https://drive.google.com/thumbnail?id=${encodeURIComponent(fileId)}&sz=w2048`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const uploadMediaFile = (
|
export const uploadMediaFile = (
|
||||||
mediaType: UploadMediaType,
|
mediaType: UploadMediaType,
|
||||||
file: File,
|
file: File,
|
||||||
|
|
@ -97,7 +57,7 @@ export const uploadMediaFromUrl = (
|
||||||
) =>
|
) =>
|
||||||
http.post<UploadMediaResponse>("/files/upload", {
|
http.post<UploadMediaResponse>("/files/upload", {
|
||||||
media_type: mediaType,
|
media_type: mediaType,
|
||||||
source_url: normalizeSourceUrlForUpload(mediaType, payload.sourceUrl),
|
source_url: payload.sourceUrl,
|
||||||
...(mediaType === "video" && payload.title ? { title: payload.title } : {}),
|
...(mediaType === "video" && payload.title ? { title: payload.title } : {}),
|
||||||
...(mediaType === "video" && payload.description ? { description: payload.description } : {}),
|
...(mediaType === "video" && payload.description ? { description: payload.description } : {}),
|
||||||
})
|
})
|
||||||
|
|
@ -126,8 +86,3 @@ export const resolveFileUrl = (key: string) =>
|
||||||
params: { key },
|
params: { key },
|
||||||
})
|
})
|
||||||
|
|
||||||
export const refreshFileUrl = (reference: string) =>
|
|
||||||
http.post<RefreshFileUrlResponse>("/files/refresh-url", {
|
|
||||||
reference,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
|
||||||
132
src/api/http.ts
132
src/api/http.ts
|
|
@ -12,7 +12,6 @@ let failedQueue: Array<{
|
||||||
resolve: (token: string) => void;
|
resolve: (token: string) => void;
|
||||||
reject: (error: Error) => void;
|
reject: (error: Error) => void;
|
||||||
}> = [];
|
}> = [];
|
||||||
const TOKEN_REFRESH_BUFFER_SECONDS = 120;
|
|
||||||
|
|
||||||
const processQueue = (error: Error | null, token: string | null = null) => {
|
const processQueue = (error: Error | null, token: string | null = null) => {
|
||||||
failedQueue.forEach((prom) => {
|
failedQueue.forEach((prom) => {
|
||||||
|
|
@ -33,68 +32,23 @@ const clearAuthAndRedirect = () => {
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 refreshAccessToken = async (): Promise<string> => {
|
||||||
|
const accessToken = localStorage.getItem("access_token");
|
||||||
const refreshToken = localStorage.getItem("refresh_token");
|
const refreshToken = localStorage.getItem("refresh_token");
|
||||||
|
const role = localStorage.getItem("role");
|
||||||
|
const memberId = localStorage.getItem("member_id");
|
||||||
|
|
||||||
if (!refreshToken) {
|
if (!refreshToken || !memberId) {
|
||||||
throw new Error("No refresh token available");
|
throw new Error("No refresh token available");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${import.meta.env.VITE_API_BASE_URL}/team/refresh`,
|
`${import.meta.env.VITE_API_BASE_URL}/auth/refresh`,
|
||||||
{
|
{
|
||||||
|
access_token: accessToken,
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
|
role: role || "admin",
|
||||||
|
member_id: Number(memberId),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -111,47 +65,9 @@ const refreshAccessToken = async (): Promise<string> => {
|
||||||
return newAccessToken;
|
return newAccessToken;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getValidAccessToken = async (forceRefresh = false): Promise<string> => {
|
|
||||||
const currentToken = localStorage.getItem("access_token");
|
|
||||||
if (!forceRefresh && currentToken && !isAccessTokenExpiringSoon(currentToken)) {
|
|
||||||
return currentToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRefreshing) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
failedQueue.push({ resolve, reject });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
isRefreshing = true;
|
|
||||||
try {
|
|
||||||
const newToken = await refreshAccessToken();
|
|
||||||
processQueue(null, newToken);
|
|
||||||
return newToken;
|
|
||||||
} catch (refreshError) {
|
|
||||||
processQueue(refreshError as Error, null);
|
|
||||||
clearAuthAndRedirect();
|
|
||||||
throw refreshError;
|
|
||||||
} finally {
|
|
||||||
isRefreshing = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Attach access token to every request
|
// Attach access token to every request
|
||||||
http.interceptors.request.use(async (config) => {
|
http.interceptors.request.use((config) => {
|
||||||
if (!shouldAttachApiAuth(config.url)) {
|
const token = localStorage.getItem("access_token");
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAuthEndpointRequest(config.url)) {
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
let token = localStorage.getItem("access_token");
|
|
||||||
if (token && isAccessTokenExpiringSoon(token)) {
|
|
||||||
token = await getValidAccessToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
@ -164,25 +80,37 @@ http.interceptors.response.use(
|
||||||
async (error: AxiosError) => {
|
async (error: AxiosError) => {
|
||||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||||
|
|
||||||
if (
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
error.response?.status === 401 &&
|
if (isRefreshing) {
|
||||||
!originalRequest._retry &&
|
return new Promise((resolve, reject) => {
|
||||||
shouldAttachApiAuth(originalRequest.url) &&
|
failedQueue.push({ resolve, reject });
|
||||||
!isAuthEndpointRequest(originalRequest.url)
|
})
|
||||||
) {
|
.then((token) => {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||||
|
return http(originalRequest);
|
||||||
|
})
|
||||||
|
.catch((err) => Promise.reject(err));
|
||||||
|
}
|
||||||
|
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newToken = await getValidAccessToken(true);
|
const newToken = await refreshAccessToken();
|
||||||
|
processQueue(null, newToken);
|
||||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||||
return http(originalRequest);
|
return http(originalRequest);
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
|
processQueue(refreshError as Error, null);
|
||||||
|
clearAuthAndRedirect();
|
||||||
return Promise.reject(refreshError);
|
return Promise.reject(refreshError);
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backend is down (network error, timeout, connection refused)
|
// Backend is down (network error, timeout, connection refused)
|
||||||
if (!error.response && shouldAttachApiAuth(originalRequest.url)) {
|
if (!error.response) {
|
||||||
clearAuthAndRedirect();
|
clearAuthAndRedirect();
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import type {
|
||||||
GetRolesParams,
|
GetRolesParams,
|
||||||
CreateRoleRequest,
|
CreateRoleRequest,
|
||||||
CreateRoleResponse,
|
CreateRoleResponse,
|
||||||
DeleteRoleResponse,
|
|
||||||
SetRolePermissionsRequest,
|
SetRolePermissionsRequest,
|
||||||
GetPermissionsResponse,
|
GetPermissionsResponse,
|
||||||
} from "../types/rbac.types"
|
} from "../types/rbac.types"
|
||||||
|
|
@ -27,6 +26,3 @@ export const setRolePermissions = (roleId: number, data: SetRolePermissionsReque
|
||||||
|
|
||||||
export const getAllPermissions = () =>
|
export const getAllPermissions = () =>
|
||||||
http.get<GetPermissionsResponse>("/rbac/permissions")
|
http.get<GetPermissionsResponse>("/rbac/permissions")
|
||||||
|
|
||||||
export const deleteRole = (roleId: number) =>
|
|
||||||
http.delete<DeleteRoleResponse>(`/rbac/roles/${roleId}`)
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { AppLayout } from "../layouts/AppLayout";
|
||||||
import { DashboardPage } from "../pages/DashboardPage";
|
import { DashboardPage } from "../pages/DashboardPage";
|
||||||
import { AnalyticsPage } from "../pages/analytics/AnalyticsPage";
|
import { AnalyticsPage } from "../pages/analytics/AnalyticsPage";
|
||||||
import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout";
|
import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout";
|
||||||
|
import { CourseCategoryPage } from "../pages/content-management/CourseCategoryPage";
|
||||||
import { AllCoursesPage } from "../pages/content-management/AllCoursesPage";
|
import { AllCoursesPage } from "../pages/content-management/AllCoursesPage";
|
||||||
import { CourseFlowBuilderPage } from "../pages/content-management/CourseFlowBuilderPage";
|
import { CourseFlowBuilderPage } from "../pages/content-management/CourseFlowBuilderPage";
|
||||||
import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage";
|
import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage";
|
||||||
|
|
@ -46,7 +47,7 @@ import { PracticeDetailsPage } from "../pages/content-management/PracticeDetails
|
||||||
import { PracticeMembersPage } from "../pages/content-management/PracticeMembersPage";
|
import { PracticeMembersPage } from "../pages/content-management/PracticeMembersPage";
|
||||||
import { QuestionsPage } from "../pages/content-management/QuestionsPage";
|
import { QuestionsPage } from "../pages/content-management/QuestionsPage";
|
||||||
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage";
|
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage";
|
||||||
import { HumanLanguageHierarchyPage } from "../pages/content-management/HumanLanguageHierarchyPage";
|
import { HumanLanguagePage } from "../pages/content-management/HumanLanguagePage";
|
||||||
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage";
|
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage";
|
||||||
import { UserLogPage } from "../pages/user-log/UserLogPage";
|
import { UserLogPage } from "../pages/user-log/UserLogPage";
|
||||||
import { IssuesPage } from "../pages/issues/IssuesPage";
|
import { IssuesPage } from "../pages/issues/IssuesPage";
|
||||||
|
|
@ -90,10 +91,10 @@ export function AppRoutes() {
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/content" element={<ContentManagementLayout />}>
|
<Route path="/content" element={<ContentManagementLayout />}>
|
||||||
<Route index element={<Navigate to="practices" replace />} />
|
<Route index element={<CourseCategoryPage />} />
|
||||||
<Route path="courses" element={<AllCoursesPage />} />
|
<Route path="courses" element={<AllCoursesPage />} />
|
||||||
<Route path="flows" element={<CourseFlowBuilderPage />} />
|
<Route path="flows" element={<CourseFlowBuilderPage />} />
|
||||||
<Route path="human-language" element={<HumanLanguageHierarchyPage />} />
|
<Route path="human-language" element={<HumanLanguagePage />} />
|
||||||
<Route
|
<Route
|
||||||
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/add-practice"
|
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/add-practice"
|
||||||
element={<AddNewPracticePage />}
|
element={<AddNewPracticePage />}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@ import { Select } from "../ui/select"
|
||||||
import { Button } from "../ui/button"
|
import { Button } from "../ui/button"
|
||||||
import { SpinnerIcon } from "../ui/spinner-icon"
|
import { SpinnerIcon } from "../ui/spinner-icon"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { ResolvedAudio } from "../media/ResolvedAudio"
|
|
||||||
import { ResolvedImage } from "../media/ResolvedImage"
|
|
||||||
|
|
||||||
export type PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
|
export type PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
|
||||||
export type PracticeQuestionEditorDifficulty = "EASY" | "MEDIUM" | "HARD"
|
export type PracticeQuestionEditorDifficulty = "EASY" | "MEDIUM" | "HARD"
|
||||||
|
|
@ -817,7 +815,7 @@ export function PracticeQuestionEditorFields({
|
||||||
disabled={controlsDisabled}
|
disabled={controlsDisabled}
|
||||||
/>
|
/>
|
||||||
{voicePreviewUrl ? (
|
{voicePreviewUrl ? (
|
||||||
<ResolvedAudio controls src={voicePreviewUrl} className="h-10 w-full max-w-md" />
|
<audio controls src={voicePreviewUrl} className="h-10 w-full max-w-md" />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -864,7 +862,7 @@ export function PracticeQuestionEditorFields({
|
||||||
disabled={controlsDisabled}
|
disabled={controlsDisabled}
|
||||||
/>
|
/>
|
||||||
{samplePreviewUrl ? (
|
{samplePreviewUrl ? (
|
||||||
<ResolvedAudio controls src={samplePreviewUrl} className="h-10 w-full max-w-md" />
|
<audio controls src={samplePreviewUrl} className="h-10 w-full max-w-md" />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -900,7 +898,7 @@ export function PracticeQuestionEditorFields({
|
||||||
disabled={controlsDisabled}
|
disabled={controlsDisabled}
|
||||||
/>
|
/>
|
||||||
{imagePreviewUrl ? (
|
{imagePreviewUrl ? (
|
||||||
<ResolvedImage
|
<img
|
||||||
src={imagePreviewUrl}
|
src={imagePreviewUrl}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-28 w-28 rounded-md border border-grayScale-200 object-cover"
|
className="h-28 w-28 rounded-md border border-grayScale-200 object-cover"
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { useEffect, useState, type AudioHTMLAttributes } from "react"
|
|
||||||
import { resolveDisplayMediaUrl } from "../../lib/mediaUrl"
|
|
||||||
|
|
||||||
type ResolvedAudioProps = AudioHTMLAttributes<HTMLAudioElement> & {
|
|
||||||
src?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ResolvedAudio({ src, ...audioProps }: ResolvedAudioProps) {
|
|
||||||
const [resolvedSrc, setResolvedSrc] = useState("")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
;(async () => {
|
|
||||||
const raw = (src ?? "").trim()
|
|
||||||
if (!raw) {
|
|
||||||
setResolvedSrc("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const next = await resolveDisplayMediaUrl(raw)
|
|
||||||
if (!cancelled) setResolvedSrc(next || raw)
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) setResolvedSrc(raw)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [src])
|
|
||||||
|
|
||||||
if (!resolvedSrc) return null
|
|
||||||
return <audio {...audioProps} src={resolvedSrc} />
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { useEffect, useState, type ImgHTMLAttributes } from "react"
|
|
||||||
import { resolveDisplayMediaUrl } from "../../lib/mediaUrl"
|
|
||||||
|
|
||||||
type ResolvedImageProps = ImgHTMLAttributes<HTMLImageElement> & {
|
|
||||||
src?: string | null
|
|
||||||
fallbackSrc?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ResolvedImage({ src, fallbackSrc, ...imgProps }: ResolvedImageProps) {
|
|
||||||
const [resolvedSrc, setResolvedSrc] = useState("")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
;(async () => {
|
|
||||||
const raw = (src ?? "").trim()
|
|
||||||
if (!raw) {
|
|
||||||
setResolvedSrc("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const next = await resolveDisplayMediaUrl(raw)
|
|
||||||
if (!cancelled) setResolvedSrc(next || raw)
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) setResolvedSrc(raw)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [src])
|
|
||||||
|
|
||||||
const finalSrc = resolvedSrc || fallbackSrc || ""
|
|
||||||
if (!finalSrc) return null
|
|
||||||
return <img {...imgProps} src={finalSrc} />
|
|
||||||
}
|
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
UserCircle2,
|
UserCircle2,
|
||||||
Users,
|
Users,
|
||||||
Users2,
|
Users2,
|
||||||
Settings,
|
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type ComponentType, useEffect, useState } from "react";
|
import { type ComponentType, useEffect, useState } from "react";
|
||||||
|
|
@ -40,7 +39,6 @@ const navItems: NavItem[] = [
|
||||||
{ label: "Analytics", to: "/analytics", icon: BarChart3 },
|
{ label: "Analytics", to: "/analytics", icon: BarChart3 },
|
||||||
{ label: "Team Management", to: "/team", icon: Users2 },
|
{ label: "Team Management", to: "/team", icon: Users2 },
|
||||||
{ label: "Profile", to: "/profile", icon: UserCircle2 },
|
{ label: "Profile", to: "/profile", icon: UserCircle2 },
|
||||||
{ label: "Settings", to: "/settings", icon: Settings },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
type SidebarProps = {
|
type SidebarProps = {
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export function Topbar({ onSidebarToggle }: TopbarProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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">
|
<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">
|
||||||
{/* Sidebar toggle */}
|
{/* Sidebar toggle */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,16 @@
|
||||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react"
|
import { useState, useCallback } from "react"
|
||||||
import { Navigate, Outlet, useLocation } from "react-router-dom"
|
import { Navigate, Outlet } from "react-router-dom"
|
||||||
import { Sidebar } from "../components/sidebar/Sidebar"
|
import { Sidebar } from "../components/sidebar/Sidebar"
|
||||||
import { Topbar } from "../components/topbar/Topbar"
|
import { Topbar } from "../components/topbar/Topbar"
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
const mainRef = useRef<HTMLElement | null>(null)
|
|
||||||
const previousRouteKeyRef = useRef<string>("")
|
|
||||||
const location = useLocation()
|
|
||||||
const scrollStoragePrefix = "app:scroll:"
|
|
||||||
const routeKey = useMemo(() => `${location.pathname}${location.search}`, [location.pathname, location.search])
|
|
||||||
|
|
||||||
const token = localStorage.getItem("access_token")
|
const token = localStorage.getItem("access_token")
|
||||||
|
if (!token) {
|
||||||
|
return <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
|
||||||
const handleSidebarToggle = useCallback(() => {
|
const handleSidebarToggle = useCallback(() => {
|
||||||
setSidebarOpen((prev) => !prev)
|
setSidebarOpen((prev) => !prev)
|
||||||
|
|
@ -22,43 +20,6 @@ export function AppLayout() {
|
||||||
setSidebarOpen(false)
|
setSidebarOpen(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const container = mainRef.current
|
|
||||||
if (!container) return
|
|
||||||
|
|
||||||
const saveScroll = (key: string) => {
|
|
||||||
sessionStorage.setItem(`${scrollStoragePrefix}${key}`, String(container.scrollTop || 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousKey = previousRouteKeyRef.current
|
|
||||||
if (previousKey && previousKey !== routeKey) {
|
|
||||||
saveScroll(previousKey)
|
|
||||||
}
|
|
||||||
previousRouteKeyRef.current = routeKey
|
|
||||||
|
|
||||||
const restoreRaw = sessionStorage.getItem(`${scrollStoragePrefix}${routeKey}`)
|
|
||||||
const restoreTop = restoreRaw ? Number(restoreRaw) : 0
|
|
||||||
const top = Number.isFinite(restoreTop) && restoreTop > 0 ? restoreTop : 0
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
container.scrollTo({ top, behavior: "auto" })
|
|
||||||
})
|
|
||||||
|
|
||||||
const onScroll = () => saveScroll(routeKey)
|
|
||||||
const onBeforeUnload = () => saveScroll(routeKey)
|
|
||||||
container.addEventListener("scroll", onScroll, { passive: true })
|
|
||||||
window.addEventListener("beforeunload", onBeforeUnload)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
saveScroll(routeKey)
|
|
||||||
container.removeEventListener("scroll", onScroll)
|
|
||||||
window.removeEventListener("beforeunload", onBeforeUnload)
|
|
||||||
}
|
|
||||||
}, [routeKey])
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return <Navigate to="/login" replace />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-grayScale-100">
|
<div className="flex min-h-screen bg-grayScale-100">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
|
|
@ -73,7 +34,7 @@ export function AppLayout() {
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Topbar onSidebarToggle={handleSidebarToggle} />
|
<Topbar onSidebarToggle={handleSidebarToggle} />
|
||||||
<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">
|
<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">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
<footer className="border-t bg-grayScale-50 px-4 py-3 lg:px-6">
|
<footer className="border-t bg-grayScale-50 px-4 py-3 lg:px-6">
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import { refreshFileUrl, resolveFileUrl } from "../api/files.api"
|
|
||||||
|
|
||||||
const HTTP_REGEX = /^https?:\/\//i
|
|
||||||
|
|
||||||
export function isHttpUrl(value: string): boolean {
|
|
||||||
return HTTP_REGEX.test(value.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSignedMinioUrl(value: string): boolean {
|
|
||||||
const trimmed = value.trim()
|
|
||||||
if (!isHttpUrl(trimmed)) return false
|
|
||||||
try {
|
|
||||||
const url = new URL(trimmed)
|
|
||||||
return (
|
|
||||||
url.host === "s3.yimaruacademy.com" &&
|
|
||||||
(url.searchParams.has("X-Amz-Signature") || url.searchParams.has("X-Amz-Expires"))
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveDisplayMediaUrl(value: string): Promise<string> {
|
|
||||||
const trimmed = value.trim()
|
|
||||||
if (!trimmed) return ""
|
|
||||||
|
|
||||||
if (isHttpUrl(trimmed)) {
|
|
||||||
if (!isSignedMinioUrl(trimmed)) return trimmed
|
|
||||||
try {
|
|
||||||
const refreshed = await refreshFileUrl(trimmed)
|
|
||||||
const refreshedUrl = refreshed.data?.data?.url?.trim()
|
|
||||||
return refreshedUrl || trimmed
|
|
||||||
} catch {
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = await resolveFileUrl(trimmed)
|
|
||||||
return resolved.data?.data?.url?.trim() || ""
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { resolveDisplayMediaUrl } from "./mediaUrl"
|
import { resolveFileUrl } from "../api/files.api"
|
||||||
|
|
||||||
export function normalizeObjectKey(value: string): string {
|
export function normalizeObjectKey(value: string): string {
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
|
|
@ -12,7 +12,10 @@ export function normalizeObjectKey(value: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveMediaPreviewUrl(value: string): Promise<string> {
|
export async function resolveMediaPreviewUrl(value: string): Promise<string> {
|
||||||
const normalized = normalizeObjectKey(value)
|
if (!value.trim()) return ""
|
||||||
if (!normalized) return ""
|
if (value.startsWith("http://") || value.startsWith("https://")) return value
|
||||||
return resolveDisplayMediaUrl(normalized)
|
const key = normalizeObjectKey(value)
|
||||||
|
if (!key) return ""
|
||||||
|
const res = await resolveFileUrl(key)
|
||||||
|
return res.data?.data?.url ?? ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
// Vimeo private/unlisted links often come as /<videoId>/<hash> instead of ?h=<hash>.
|
|
||||||
const hashFromPath = (() => {
|
|
||||||
const videoIdx = segments.findIndex((segment) => segment === videoId);
|
|
||||||
if (videoIdx < 0) return null;
|
|
||||||
const maybeHash = segments[videoIdx + 1];
|
|
||||||
if (!maybeHash) return null;
|
|
||||||
return /^[a-zA-Z0-9]+$/.test(maybeHash) ? maybeHash : null;
|
|
||||||
})();
|
|
||||||
const hash = parsed.searchParams.get("h") || hashFromPath;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
Eye,
|
Eye,
|
||||||
|
|
@ -13,43 +13,21 @@ import {
|
||||||
Shield,
|
Shield,
|
||||||
Sun,
|
Sun,
|
||||||
User,
|
User,
|
||||||
CreditCard,
|
|
||||||
AlertTriangle,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "../components/ui/card";
|
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Select } from "../components/ui/select";
|
import { Select } from "../components/ui/select";
|
||||||
import { Separator } from "../components/ui/separator";
|
import { Separator } from "../components/ui/separator";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "../components/ui/dialog";
|
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { SpinnerIcon } from "../components/ui/spinner-icon";
|
import { SpinnerIcon } from "../components/ui/spinner-icon";
|
||||||
import { getMyProfile, updateProfile } from "../api/users.api";
|
import { getMyProfile, updateProfile } from "../api/users.api";
|
||||||
import type { UserProfileData } from "../types/user.types";
|
import type { UserProfileData } from "../types/user.types";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
type SettingsTab =
|
type SettingsTab = "profile" | "security" | "notifications" | "appearance";
|
||||||
| "subscription"
|
|
||||||
| "profile"
|
|
||||||
| "security"
|
|
||||||
| "notifications"
|
|
||||||
| "appearance";
|
|
||||||
|
|
||||||
const tabs: { id: SettingsTab; label: string; icon: typeof User }[] = [
|
const tabs: { id: SettingsTab; label: string; icon: typeof User }[] = [
|
||||||
{ id: "subscription", label: "Subscription", icon: CreditCard },
|
|
||||||
{ id: "profile", label: "Profile", icon: User },
|
{ id: "profile", label: "Profile", icon: User },
|
||||||
{ id: "security", label: "Security", icon: Shield },
|
{ id: "security", label: "Security", icon: Shield },
|
||||||
{ id: "notifications", label: "Notifications", icon: Bell },
|
{ id: "notifications", label: "Notifications", icon: Bell },
|
||||||
|
|
@ -70,14 +48,14 @@ function Toggle({
|
||||||
aria-checked={enabled}
|
aria-checked={enabled}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none",
|
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2",
|
||||||
enabled ? "bg-brand-500" : "bg-grayScale-200",
|
enabled ? "bg-brand-500" : "bg-grayScale-200"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform",
|
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
||||||
enabled ? "translate-x-5" : "translate-x-0.5",
|
enabled ? "translate-x-6" : "translate-x-1"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -90,20 +68,20 @@ function SettingRow({
|
||||||
description,
|
description,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
icon: any;
|
icon: typeof User;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-4 rounded-[6px] px-3 py-4 transition-colors hover:bg-grayScale-100/50">
|
<div className="flex items-center justify-between gap-4 rounded-lg px-3 py-4 transition-colors hover:bg-grayScale-100/50">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-[6px] bg-grayScale-100 text-grayScale-400">
|
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-grayScale-100 text-grayScale-400">
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-grayScale-800">{title}</p>
|
<p className="text-sm font-medium text-grayScale-600">{title}</p>
|
||||||
<p className="mt-0.5 text-xs text-grayScale-500">{description}</p>
|
<p className="mt-0.5 text-xs text-grayScale-400">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0">{children}</div>
|
<div className="shrink-0">{children}</div>
|
||||||
|
|
@ -111,143 +89,34 @@ function SettingRow({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Subscription Tab ---
|
function LoadingSkeleton() {
|
||||||
|
|
||||||
function SubscriptionTab() {
|
|
||||||
const [subs, setSubs] = useState([
|
|
||||||
{
|
|
||||||
id: "auto_renew",
|
|
||||||
name: "Auto-renewal",
|
|
||||||
desc: "Automatically renew your subscription when it expires",
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "marketing_emails",
|
|
||||||
name: "Marketing Emails",
|
|
||||||
desc: "Receive updates about new features and promotions",
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "priority_support",
|
|
||||||
name: "Priority Support",
|
|
||||||
desc: "Access 24/7 priority customer support",
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [pendingToggle, setPendingToggle] = useState<string | null>(null);
|
|
||||||
const [showWarning, setShowWarning] = useState(false);
|
|
||||||
|
|
||||||
const handleToggle = (id: string) => {
|
|
||||||
const item = subs.find((s) => s.id === id);
|
|
||||||
if (item?.enabled) {
|
|
||||||
setPendingToggle(id);
|
|
||||||
setShowWarning(true);
|
|
||||||
} else {
|
|
||||||
setSubs((prev) =>
|
|
||||||
prev.map((s) => (s.id === id ? { ...s, enabled: true } : s)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmToggleOff = () => {
|
|
||||||
if (pendingToggle) {
|
|
||||||
setSubs((prev) =>
|
|
||||||
prev.map((s) =>
|
|
||||||
s.id === pendingToggle ? { ...s, enabled: false } : s,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
setShowWarning(false);
|
|
||||||
setPendingToggle(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
<div className="mx-auto w-full max-w-5xl space-y-6 px-4 py-8 sm:px-6">
|
||||||
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden">
|
<div className="animate-pulse space-y-6">
|
||||||
<CardHeader className="pb-3 border-b border-grayScale-50">
|
<div className="h-7 w-32 rounded-lg bg-grayScale-100" />
|
||||||
<CardTitle className="text-sm font-bold text-grayScale-900">
|
<div className="flex gap-2">
|
||||||
Subscription Features
|
{[1, 2, 3, 4].map((i) => (
|
||||||
</CardTitle>
|
<div key={i} className="h-10 w-28 rounded-lg bg-grayScale-100" />
|
||||||
<p className="text-[11px] text-grayScale-500">
|
|
||||||
Customize your subscription experience and management preferences
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-0 p-0">
|
|
||||||
{subs.map((sub, idx) => (
|
|
||||||
<React.Fragment key={sub.id}>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"px-2",
|
|
||||||
idx < subs.length - 1 && "border-b border-grayScale-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<SettingRow
|
|
||||||
icon={CreditCard}
|
|
||||||
title={sub.name}
|
|
||||||
description={sub.desc}
|
|
||||||
>
|
|
||||||
<Toggle
|
|
||||||
enabled={sub.enabled}
|
|
||||||
onToggle={() => handleToggle(sub.id)}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
<div className="rounded-2xl border border-grayScale-100 p-6">
|
||||||
|
<div className="space-y-6">
|
||||||
<Dialog open={showWarning} onOpenChange={setShowWarning}>
|
{[1, 2, 3, 4].map((j) => (
|
||||||
<DialogContent className="max-w-md p-0 overflow-hidden border border-grayScale-100 rounded-[12px] shadow-2xl">
|
<div key={j} className="flex items-center justify-between">
|
||||||
<div className="relative p-8">
|
<div className="space-y-2">
|
||||||
<div className="flex items-start gap-5 mb-6">
|
<div className="h-4 w-32 rounded bg-grayScale-100" />
|
||||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-500 border border-red-100">
|
<div className="h-3 w-48 rounded bg-grayScale-100" />
|
||||||
<AlertTriangle className="h-7 w-7" />
|
</div>
|
||||||
|
<div className="h-10 w-48 rounded-lg bg-grayScale-100" />
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-1">
|
))}
|
||||||
<h3 className="text-xl font-bold text-grayScale-900 tracking-tight">
|
|
||||||
Are you absolutely sure?
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-grayScale-500 mt-1">
|
|
||||||
Disabling this feature might limit your experience.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-grayScale-50/80 border border-grayScale-100 p-5 rounded-[8px] mb-8">
|
|
||||||
<p className="text-sm text-grayScale-600 leading-relaxed font-medium">
|
|
||||||
By turning this off, you will no longer receive the benefits
|
|
||||||
associated with this feature. Some changes might take up to 24
|
|
||||||
hours to reflect.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={confirmToggleOff}
|
|
||||||
className="w-full rounded-[8px] py-6 text-sm font-bold bg-red-500 hover:bg-red-600 text-white border-none shadow-sm transition-all active:scale-[0.98]"
|
|
||||||
>
|
|
||||||
Yes, Disable Feature
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowWarning(false)}
|
|
||||||
className="w-full rounded-[8px] py-6 text-sm font-bold border-grayScale-200 text-grayScale-600 hover:bg-grayScale-50 transition-all active:scale-[0.98]"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Other Tabs (Existing, but with sidebar layout updates) ---
|
|
||||||
|
|
||||||
function ProfileTab({ profile }: { profile: UserProfileData }) {
|
function ProfileTab({ profile }: { profile: UserProfileData }) {
|
||||||
const [firstName, setFirstName] = useState(profile.first_name);
|
const [firstName, setFirstName] = useState(profile.first_name);
|
||||||
const [lastName, setLastName] = useState(profile.last_name);
|
const [lastName, setLastName] = useState(profile.last_name);
|
||||||
|
|
@ -273,88 +142,79 @@ function ProfileTab({ profile }: { profile: UserProfileData }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
<div className="space-y-6">
|
||||||
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden">
|
<Card className="border border-grayScale-100">
|
||||||
<div className="h-1 w-full bg-brand-500" />
|
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-600" />
|
||||||
<CardHeader className="pb-3 border-b border-grayScale-50">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-sm font-bold text-grayScale-900">
|
<div className="flex items-center gap-3">
|
||||||
Personal Information
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-sm">
|
||||||
</CardTitle>
|
<User className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
||||||
|
Personal Information
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5 pb-6">
|
<CardContent className="space-y-5 pb-6">
|
||||||
<div className="grid gap-5 sm:grid-cols-2">
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
<label className="text-xs font-medium text-grayScale-500">First Name</label>
|
||||||
First Name
|
<Input value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={firstName}
|
|
||||||
onChange={(e) => setFirstName(e.target.value)}
|
|
||||||
className="rounded-[6px]"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
<label className="text-xs font-medium text-grayScale-500">Last Name</label>
|
||||||
Last Name
|
<Input value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={lastName}
|
|
||||||
onChange={(e) => setLastName(e.target.value)}
|
|
||||||
className="rounded-[6px]"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
<label className="text-xs font-medium text-grayScale-500">Nickname</label>
|
||||||
Nickname
|
<Input value={nickName} onChange={(e) => setNickName(e.target.value)} />
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={nickName}
|
|
||||||
onChange={(e) => setNickName(e.target.value)}
|
|
||||||
className="rounded-[6px]"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden">
|
<Card className="border border-grayScale-100">
|
||||||
<div className="h-1 w-full bg-brand-400" />
|
<div className="h-1 w-full bg-gradient-to-r from-brand-400 to-brand-500" />
|
||||||
<CardHeader className="pb-3 border-b border-grayScale-50">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-sm font-bold text-grayScale-900">
|
<div className="flex items-center gap-3">
|
||||||
Preferences
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-400 to-brand-500 text-white shadow-sm">
|
||||||
</CardTitle>
|
<Languages className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
||||||
|
Preferences
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5 pb-6">
|
<CardContent className="space-y-5 pb-6">
|
||||||
<div className="grid gap-5 sm:grid-cols-2">
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
<label className="text-xs font-medium text-grayScale-500">Preferred Language</label>
|
||||||
Preferred Language
|
<Select value={language} onChange={(e) => setLanguage(e.target.value)}>
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={language}
|
|
||||||
onChange={(e) => setLanguage(e.target.value)}
|
|
||||||
className="rounded-[6px]"
|
|
||||||
>
|
|
||||||
<option value="en">English</option>
|
<option value="en">English</option>
|
||||||
<option value="am">Amharic</option>
|
<option value="am">Amharic</option>
|
||||||
<option value="or">Afan Oromo</option>
|
<option value="or">Afan Oromo</option>
|
||||||
<option value="ti">Tigrinya</option>
|
<option value="ti">Tigrinya</option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-grayScale-500">Timezone</label>
|
||||||
|
<Select defaultValue="eat">
|
||||||
|
<option value="eat">East Africa Time (UTC+3)</option>
|
||||||
|
<option value="utc">UTC</option>
|
||||||
|
<option value="est">Eastern Time (UTC-5)</option>
|
||||||
|
<option value="pst">Pacific Time (UTC-8)</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button onClick={handleSave} disabled={saving} className="min-w-[140px]">
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="min-w-[140px] rounded-[6px] font-bold"
|
|
||||||
>
|
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<SpinnerIcon className="h-4 w-4" />
|
<SpinnerIcon className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<Save className="h-4 w-4 mr-2" />
|
<Save className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
{saving ? "Saving…" : "Save Changes"}
|
{saving ? "Saving…" : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -380,102 +240,96 @@ function SecurityTab() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
<div className="space-y-6">
|
||||||
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden">
|
<Card className="border border-grayScale-100">
|
||||||
<div className="h-1 w-full bg-brand-600" />
|
<div className="h-1 w-full bg-gradient-to-r from-brand-600 to-brand-500" />
|
||||||
<CardHeader className="pb-3 border-b border-grayScale-50">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-sm font-bold text-grayScale-900">
|
<div className="flex items-center gap-3">
|
||||||
Change Password
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-600 to-brand-500 text-white shadow-sm">
|
||||||
</CardTitle>
|
<KeyRound className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
||||||
|
Change Password
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5 pb-6">
|
<CardContent className="space-y-5 pb-6">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
<label className="text-xs font-medium text-grayScale-500">Current Password</label>
|
||||||
Current Password
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input type={showCurrent ? "text" : "password"} placeholder="Enter current password" />
|
||||||
type={showCurrent ? "text" : "password"}
|
|
||||||
placeholder="Enter current password"
|
|
||||||
className="rounded-[6px]"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowCurrent(!showCurrent)}
|
onClick={() => setShowCurrent(!showCurrent)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
{showCurrent ? (
|
{showCurrent ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
<EyeOff className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-5 sm:grid-cols-2">
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
<label className="text-xs font-medium text-grayScale-500">New Password</label>
|
||||||
New Password
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input type={showNew ? "text" : "password"} placeholder="Enter new password" />
|
||||||
type={showNew ? "text" : "password"}
|
|
||||||
placeholder="Enter new password"
|
|
||||||
className="rounded-[6px]"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowNew(!showNew)}
|
onClick={() => setShowNew(!showNew)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
{showNew ? (
|
{showNew ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
<EyeOff className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
<label className="text-xs font-medium text-grayScale-500">Confirm New Password</label>
|
||||||
Confirm New Password
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input type={showConfirm ? "text" : "password"} placeholder="Confirm new password" />
|
||||||
type={showConfirm ? "text" : "password"}
|
|
||||||
placeholder="Confirm new password"
|
|
||||||
className="rounded-[6px]"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowConfirm(!showConfirm)}
|
onClick={() => setShowConfirm(!showConfirm)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
{showConfirm ? (
|
{showConfirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
<EyeOff className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button onClick={handleChangePassword} disabled={saving} className="min-w-[160px]">
|
||||||
onClick={handleChangePassword}
|
|
||||||
disabled={saving}
|
|
||||||
className="min-w-[160px] rounded-[6px] font-bold"
|
|
||||||
>
|
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<SpinnerIcon className="h-4 w-4" />
|
<SpinnerIcon className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<Lock className="h-4 w-4 mr-2" />
|
<Lock className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
{saving ? "Updating…" : "Update Password"}
|
{saving ? "Updating…" : "Update Password"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border border-grayScale-100">
|
||||||
|
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-400" />
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-400 text-white shadow-sm">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
||||||
|
Two-Factor Authentication
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pb-6">
|
||||||
|
<SettingRow
|
||||||
|
icon={Shield}
|
||||||
|
title="Enable 2FA"
|
||||||
|
description="Add an extra layer of security to your account"
|
||||||
|
>
|
||||||
|
<Toggle enabled={false} onToggle={() => toast.info("2FA coming soon")} />
|
||||||
|
</SettingRow>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -484,14 +338,20 @@ function NotificationsTab() {
|
||||||
const [emailNotifs, setEmailNotifs] = useState(true);
|
const [emailNotifs, setEmailNotifs] = useState(true);
|
||||||
const [pushNotifs, setPushNotifs] = useState(true);
|
const [pushNotifs, setPushNotifs] = useState(true);
|
||||||
const [loginAlerts, setLoginAlerts] = useState(true);
|
const [loginAlerts, setLoginAlerts] = useState(true);
|
||||||
|
const [weeklyDigest, setWeeklyDigest] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-300">
|
<Card className="border border-grayScale-100">
|
||||||
<div className="h-1 w-full bg-brand-500" />
|
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-600" />
|
||||||
<CardHeader className="pb-3 border-b border-grayScale-50">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-sm font-bold text-grayScale-900">
|
<div className="flex items-center gap-3">
|
||||||
Notification Preferences
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-sm">
|
||||||
</CardTitle>
|
<Bell className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
||||||
|
Notification Preferences
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-1 pb-6">
|
<CardContent className="space-y-1 pb-6">
|
||||||
<SettingRow
|
<SettingRow
|
||||||
|
|
@ -499,32 +359,31 @@ function NotificationsTab() {
|
||||||
title="Email Notifications"
|
title="Email Notifications"
|
||||||
description="Receive important updates via email"
|
description="Receive important updates via email"
|
||||||
>
|
>
|
||||||
<Toggle
|
<Toggle enabled={emailNotifs} onToggle={() => setEmailNotifs(!emailNotifs)} />
|
||||||
enabled={emailNotifs}
|
|
||||||
onToggle={() => setEmailNotifs(!emailNotifs)}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<Separator className="bg-grayScale-50" />
|
<Separator />
|
||||||
<SettingRow
|
<SettingRow
|
||||||
icon={Bell}
|
icon={Bell}
|
||||||
title="Push Notifications"
|
title="Push Notifications"
|
||||||
description="Get notified in the browser"
|
description="Get notified in the browser"
|
||||||
>
|
>
|
||||||
<Toggle
|
<Toggle enabled={pushNotifs} onToggle={() => setPushNotifs(!pushNotifs)} />
|
||||||
enabled={pushNotifs}
|
|
||||||
onToggle={() => setPushNotifs(!pushNotifs)}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<Separator className="bg-grayScale-50" />
|
<Separator />
|
||||||
<SettingRow
|
<SettingRow
|
||||||
icon={Shield}
|
icon={Shield}
|
||||||
title="Login Alerts"
|
title="Login Alerts"
|
||||||
description="Get notified when someone logs into your account"
|
description="Get notified when someone logs into your account"
|
||||||
>
|
>
|
||||||
<Toggle
|
<Toggle enabled={loginAlerts} onToggle={() => setLoginAlerts(!loginAlerts)} />
|
||||||
enabled={loginAlerts}
|
</SettingRow>
|
||||||
onToggle={() => setLoginAlerts(!loginAlerts)}
|
<Separator />
|
||||||
/>
|
<SettingRow
|
||||||
|
icon={Globe}
|
||||||
|
title="Weekly Digest"
|
||||||
|
description="Receive a weekly summary of activity"
|
||||||
|
>
|
||||||
|
<Toggle enabled={weeklyDigest} onToggle={() => setWeeklyDigest(!weeklyDigest)} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -535,12 +394,17 @@ function AppearanceTab() {
|
||||||
const [theme, setTheme] = useState<"light" | "dark" | "system">("light");
|
const [theme, setTheme] = useState<"light" | "dark" | "system">("light");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-300">
|
<Card className="border border-grayScale-100">
|
||||||
<div className="h-1 w-full bg-brand-400" />
|
<div className="h-1 w-full bg-gradient-to-r from-brand-400 to-brand-600" />
|
||||||
<CardHeader className="pb-3 border-b border-grayScale-50">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-sm font-bold text-grayScale-900">
|
<div className="flex items-center gap-3">
|
||||||
Theme
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-400 to-brand-600 text-white shadow-sm">
|
||||||
</CardTitle>
|
<Palette className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
||||||
|
Theme
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pb-6">
|
<CardContent className="pb-6">
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
|
@ -556,18 +420,16 @@ function AppearanceTab() {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTheme(id)}
|
onClick={() => setTheme(id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center gap-2.5 rounded-[6px] border-2 px-4 py-5 transition-all",
|
"flex flex-col items-center gap-2.5 rounded-xl border-2 px-4 py-5 transition-all",
|
||||||
theme === id
|
theme === id
|
||||||
? "border-brand-500 bg-brand-50 text-brand-600 shadow-sm"
|
? "border-brand-500 bg-brand-100/30 text-brand-600 shadow-sm"
|
||||||
: "border-grayScale-100 bg-white text-grayScale-400 hover:border-grayScale-200 hover:bg-grayScale-50",
|
: "border-grayScale-100 bg-white text-grayScale-400 hover:border-grayScale-200 hover:bg-grayScale-100/40"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-10 items-center justify-center rounded-[6px]",
|
"flex h-10 w-10 items-center justify-center rounded-lg",
|
||||||
theme === id
|
theme === id ? "bg-brand-500 text-white" : "bg-grayScale-100 text-grayScale-400"
|
||||||
? "bg-brand-500 text-white"
|
|
||||||
: "bg-grayScale-100 text-grayScale-400",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-5 w-5" />
|
||||||
|
|
@ -582,7 +444,7 @@ function AppearanceTab() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const [activeTab, setActiveTab] = useState<SettingsTab>("subscription");
|
const [activeTab, setActiveTab] = useState<SettingsTab>("profile");
|
||||||
const [profile, setProfile] = useState<UserProfileData | null>(null);
|
const [profile, setProfile] = useState<UserProfileData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -602,27 +464,21 @@ export function SettingsPage() {
|
||||||
fetchProfile();
|
fetchProfile();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) return <LoadingSkeleton />;
|
||||||
return (
|
|
||||||
<div className="flex h-[400px] items-center justify-center">
|
|
||||||
<SpinnerIcon className="h-8 w-8 text-brand-500" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !profile) {
|
if (error || !profile) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-5xl px-4 py-16 sm:px-6">
|
<div className="mx-auto w-full max-w-5xl px-4 py-16 sm:px-6">
|
||||||
<Card className="border-dashed border-grayScale-200 rounded-[6px]">
|
<Card className="border-dashed">
|
||||||
<CardContent className="flex flex-col items-center gap-5 p-12">
|
<CardContent className="flex flex-col items-center gap-5 p-12">
|
||||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-grayScale-100">
|
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-grayScale-100">
|
||||||
<User className="h-10 w-10 text-grayScale-300" />
|
<User className="h-10 w-10 text-grayScale-300" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-lg font-bold tracking-tight text-grayScale-900">
|
<p className="text-lg font-semibold tracking-tight text-grayScale-600">
|
||||||
{error || "Settings not available"}
|
{error || "Settings not available"}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm text-grayScale-500">
|
<p className="mt-1 text-sm text-grayScale-400">
|
||||||
Please check your connection and try again.
|
Please check your connection and try again.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -633,23 +489,40 @@ export function SettingsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6">
|
<div className="mx-auto w-full max-w-5xl space-y-6 px-4 py-8 sm:px-6">
|
||||||
<div className="mb-10 ">
|
{/* Page header */}
|
||||||
<h1 className="text-2xl font-black tracking-tight text-grayScale-700">
|
<div>
|
||||||
Settings
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Settings</h1>
|
||||||
</h1>
|
<p className="mt-1 text-sm text-grayScale-400">
|
||||||
<p className="mt-2 text-sm text-grayScale-500 ">
|
Manage your account preferences and configuration
|
||||||
Manage your account preferences, subscriptions, and system
|
|
||||||
configurations with ease
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-8">
|
{/* Tab navigation */}
|
||||||
{/* Content Area */}
|
<div className="flex gap-1 rounded-xl border border-grayScale-100 bg-grayScale-100/50 p-1">
|
||||||
<main className="min-h-[400px]">
|
{tabs.map(({ id, label, icon: Icon }) => (
|
||||||
{activeTab === "subscription" && <SubscriptionTab />}
|
<button
|
||||||
</main>
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(id)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-all",
|
||||||
|
activeTab === id
|
||||||
|
? "bg-white text-brand-600 shadow-sm"
|
||||||
|
: "text-grayScale-400 hover:text-grayScale-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
{activeTab === "profile" && <ProfileTab profile={profile} />}
|
||||||
|
{activeTab === "security" && <SecurityTab />}
|
||||||
|
{activeTab === "notifications" && <NotificationsTab />}
|
||||||
|
{activeTab === "appearance" && <AppearanceTab />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,9 @@ export function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const token = localStorage.getItem("access_token");
|
const token = localStorage.getItem("access_token");
|
||||||
|
if (token) {
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
|
|
@ -159,10 +162,6 @@ export function LoginPage() {
|
||||||
}
|
}
|
||||||
}, [googleReady, handleGoogleCallback]);
|
}, [googleReady, handleGoogleCallback]);
|
||||||
|
|
||||||
if (token) {
|
|
||||||
return <Navigate to="/dashboard" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,650 +0,0 @@
|
||||||
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,10 +1,8 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { ArrowLeft, Check } from "lucide-react";
|
||||||
import { ArrowLeft } from "lucide-react";
|
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Stepper } from "../../components/ui/stepper";
|
import { Stepper } from "../../components/ui/stepper";
|
||||||
import { createModuleLesson } from "../../api/courses.api";
|
|
||||||
|
|
||||||
import { VideoDetailStep } from "./components/video-steps/VideoDetailStep";
|
import { VideoDetailStep } from "./components/video-steps/VideoDetailStep";
|
||||||
import { ReviewPublishStep } from "./components/video-steps/ReviewPublishStep";
|
import { ReviewPublishStep } from "./components/video-steps/ReviewPublishStep";
|
||||||
|
|
@ -15,33 +13,6 @@ const STEPS = [
|
||||||
{ id: 2, label: "Review & Publish" },
|
{ 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() {
|
export function AddVideoFlow() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { level, courseId, moduleId } = useParams<{
|
const { level, courseId, moduleId } = useParams<{
|
||||||
|
|
@ -51,65 +22,24 @@ export function AddVideoFlow() {
|
||||||
}>();
|
}>();
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
const [isPublished, setIsPublished] = useState(false);
|
const [isPublished, setIsPublished] = useState(false);
|
||||||
const [formData, setFormData] = useState<AddLessonFormData>(emptyForm);
|
|
||||||
const [publishing, setPublishing] = useState(false);
|
const [formData, setFormData] = useState({
|
||||||
const [formResetKey, setFormResetKey] = useState(0);
|
title: "",
|
||||||
|
order: "1",
|
||||||
|
description: "",
|
||||||
|
thumbnail: null,
|
||||||
|
videoFile: null,
|
||||||
|
});
|
||||||
|
|
||||||
const nextStep = () => setCurrentStep((prev) => Math.min(prev + 1, 2));
|
const nextStep = () => setCurrentStep((prev) => Math.min(prev + 1, 2));
|
||||||
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
|
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||||
|
|
||||||
const backPath = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
|
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) {
|
if (isPublished) {
|
||||||
return (
|
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 ">
|
<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="mb-12 relative scale-110">
|
||||||
<div className="absolute inset-0 bg-brand-500/5 blur-3xl rounded-full" />
|
<div className="absolute inset-0 bg-brand-500/5 blur-3xl rounded-full" />
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
@ -123,37 +53,35 @@ export function AddVideoFlow() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-[26px] font-bold text-grayScale-900 mb-4">
|
<h1 className="text-[26px] font-bold text-grayScale-900 mb-4">
|
||||||
Lesson created successfully
|
Video Published Successfully!
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-grayScale-600 text-base mb-14 max-w-lg font-medium leading-relaxed">
|
<p className="text-grayScale-600 text-base mb-14 max-w-lg font-medium leading-relaxed">
|
||||||
Your lesson is now available in this module.
|
Your video is now live and available inside the selected module.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 w-full max-w-[400px]">
|
<div className="flex flex-col gap-4 w-full max-w-[400px]">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate(backPath)}
|
onClick={() => navigate(`/new-content/learn-english/${level}`)}
|
||||||
className="h-12 rounded-[6px] bg-brand-500 font-bold text-[17px] text-white transition-all active:scale-95"
|
className="h-12 rounded-[6px] bg-brand-500 font-bold text-[17px] text-white transition-all active:scale-95"
|
||||||
>
|
>
|
||||||
View module
|
Go back to Learn English
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFormData(emptyForm());
|
setFormData({
|
||||||
setFormResetKey((k) => k + 1);
|
title: "",
|
||||||
|
order: "1",
|
||||||
|
description: "",
|
||||||
|
thumbnail: null,
|
||||||
|
videoFile: null,
|
||||||
|
});
|
||||||
setIsPublished(false);
|
setIsPublished(false);
|
||||||
setCurrentStep(1);
|
setCurrentStep(1);
|
||||||
}}
|
}}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-12 rounded-[6px] border-brand-200 text-brand-500 font-bold text-[17px] active:scale-95 bg-white"
|
className="h-12 rounded-[6px] border-brand-200 text-brand-500 font-bold text-[17px] active:scale-95 bg-white"
|
||||||
>
|
>
|
||||||
Add another lesson
|
Add Another Video
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate(`/new-content/learn-english/${level}/courses`)}
|
|
||||||
variant="ghost"
|
|
||||||
className="h-10 text-grayScale-600 font-medium"
|
|
||||||
>
|
|
||||||
All courses
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -162,6 +90,7 @@ export function AddVideoFlow() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 pb-32 px-6 pt-6 min-h-screen ">
|
<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="mx-auto max-w-7xl w-full">
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -169,7 +98,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"
|
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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Back to module
|
Back to Modules
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -181,7 +110,7 @@ export function AddVideoFlow() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold text-[#0F172A] mb-10">
|
<h1 className="text-2xl font-bold text-[#0F172A] mb-10">
|
||||||
Add new lesson
|
Add New Video
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="mx-auto max-w-4xl mb-12">
|
<div className="mx-auto max-w-4xl mb-12">
|
||||||
|
|
@ -191,13 +120,13 @@ export function AddVideoFlow() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Step Content */}
|
||||||
<div className="mx-auto max-w-7xl">
|
<div className="mx-auto max-w-7xl">
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<VideoDetailStep
|
<VideoDetailStep
|
||||||
key={formResetKey}
|
|
||||||
formData={formData}
|
formData={formData}
|
||||||
setFormData={setFormData}
|
setFormData={setFormData}
|
||||||
onContinue={nextStep}
|
nextStep={nextStep}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -205,8 +134,7 @@ export function AddVideoFlow() {
|
||||||
<ReviewPublishStep
|
<ReviewPublishStep
|
||||||
formData={formData}
|
formData={formData}
|
||||||
prevStep={prevStep}
|
prevStep={prevStep}
|
||||||
onPublish={() => void handlePublish()}
|
setIsPublished={setIsPublished}
|
||||||
publishing={publishing}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import {
|
||||||
import { Textarea } from "../../components/ui/textarea"
|
import { Textarea } from "../../components/ui/textarea"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||||
|
|
||||||
type CourseWithCategory = Course & { category_name: string }
|
type CourseWithCategory = Course & { category_name: string }
|
||||||
|
|
||||||
|
|
@ -230,7 +230,10 @@ export function AllCoursesPage() {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-32">
|
<div className="flex flex-col items-center justify-center py-32">
|
||||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,60 @@
|
||||||
import { Outlet } from "react-router-dom";
|
import { NavLink, Outlet } from "react-router-dom"
|
||||||
import { ContentHierarchyList } from "./components/ContentHierarchyList";
|
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" },
|
||||||
|
]
|
||||||
|
|
||||||
export function ContentManagementLayout() {
|
export function ContentManagementLayout() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
<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="mb-8">
|
||||||
<div className="flex items-center gap-3 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" />
|
<div className="h-9 w-1 rounded-full bg-gradient-to-b from-brand-500 to-brand-600" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-[1.65rem]">
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-[1.65rem]">
|
||||||
Content Management
|
Content Management
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 max-w-2xl text-sm leading-relaxed text-grayScale-500">
|
<p className="mt-1 max-w-2xl text-sm leading-relaxed text-grayScale-500">
|
||||||
View and manage practice content for courses, modules, and lessons
|
Manage courses, speaking exercises, practices, and questions
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ContentHierarchyList />
|
|
||||||
</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 />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
import { FolderOpen, RefreshCw, BookOpen, Plus, Trash2 } from "lucide-react"
|
import { FolderOpen, RefreshCw, BookOpen, Plus } from "lucide-react"
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
||||||
import alertSrc from "../../assets/Alert.svg"
|
import alertSrc from "../../assets/Alert.svg"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
|
|
@ -11,11 +11,10 @@ import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "../../components/ui/dialog"
|
} from "../../components/ui/dialog"
|
||||||
import { getCourseCategories, createCourseCategory, deleteCourseCategory } from "../../api/courses.api"
|
import { getCourseCategories, createCourseCategory } from "../../api/courses.api"
|
||||||
import type { CourseCategory } from "../../types/course.types"
|
import type { CourseCategory } from "../../types/course.types"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
|
@ -30,8 +29,6 @@ export function CourseCategoryPage() {
|
||||||
const [newSubCategoryName, setNewSubCategoryName] = useState("")
|
const [newSubCategoryName, setNewSubCategoryName] = useState("")
|
||||||
const [pendingSubCategories, setPendingSubCategories] = useState<string[]>([])
|
const [pendingSubCategories, setPendingSubCategories] = useState<string[]>([])
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
const [deleteTarget, setDeleteTarget] = useState<CourseCategory | null>(null)
|
|
||||||
const [deleting, setDeleting] = useState(false)
|
|
||||||
|
|
||||||
const fetchCategories = async () => {
|
const fetchCategories = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -167,26 +164,12 @@ export function CourseCategoryPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
|
||||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
|
View Sub-categories
|
||||||
View Sub-categories
|
<span className="inline-block transition-transform duration-300 group-hover:translate-x-1">
|
||||||
<span className="inline-block transition-transform duration-300 group-hover:translate-x-1">
|
→
|
||||||
→
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
<button
|
</span>
|
||||||
type="button"
|
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-red-200 bg-white text-red-500 hover:bg-red-50"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setDeleteTarget(category)
|
|
||||||
}}
|
|
||||||
aria-label={`Delete category ${category.name}`}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -352,7 +335,7 @@ export function CourseCategoryPage() {
|
||||||
if (createdCategoryId && pendingSubCategories.length > 0) {
|
if (createdCategoryId && pendingSubCategories.length > 0) {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
pendingSubCategories.map((subName) =>
|
pendingSubCategories.map((subName) =>
|
||||||
createCourseCategory({ name: subName, parent_id: createdCategoryId }),
|
createCourseCategory({ name: subName }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -388,46 +371,6 @@ export function CourseCategoryPage() {
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
|
||||||
<DialogContent className="max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete category?</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{deleteTarget
|
|
||||||
? `This will permanently delete "${deleteTarget.name}" and all linked sub-categories/courses.`
|
|
||||||
: ""}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={() => setDeleteTarget(null)} disabled={deleting}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="bg-red-600 text-white hover:bg-red-700"
|
|
||||||
disabled={deleting}
|
|
||||||
onClick={async () => {
|
|
||||||
if (!deleteTarget) return
|
|
||||||
setDeleting(true)
|
|
||||||
try {
|
|
||||||
await deleteCourseCategory(deleteTarget.id)
|
|
||||||
toast.success("Category deleted")
|
|
||||||
setDeleteTarget(null)
|
|
||||||
await fetchCategories()
|
|
||||||
} catch (err: any) {
|
|
||||||
const message = err?.response?.data?.message || "Failed to delete category."
|
|
||||||
toast.error("Could not delete category", { description: message })
|
|
||||||
} finally {
|
|
||||||
setDeleting(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{deleting ? "Deleting..." : "Delete"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,633 +1,166 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import { ArrowLeft, Plus, Calendar, Plane, Clock, Hand } from "lucide-react";
|
||||||
ArrowLeft,
|
|
||||||
Plus,
|
|
||||||
Calendar,
|
|
||||||
Layers,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Card } from "../../components/ui/card";
|
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";
|
import { cn } from "../../lib/utils";
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
|
|
||||||
import alertSrc from "../../assets/Alert.svg";
|
const MODULES = [
|
||||||
import {
|
{
|
||||||
deleteTopLevelCourseModule,
|
id: "m1",
|
||||||
getProgramCourses,
|
title: "Introduction Basics",
|
||||||
getTopLevelCourseModules,
|
description: "Learn basic English words, phrases, and simple sentences.",
|
||||||
updateTopLevelCourseModule,
|
icon: Hand,
|
||||||
} from "../../api/courses.api";
|
status: "Published",
|
||||||
import { refreshFileUrl, resolveFileUrl } from "../../api/files.api";
|
gradient: "from-[#8E44AD] to-[#C39BD3]",
|
||||||
import type {
|
},
|
||||||
ProgramCourseListItem,
|
{
|
||||||
TopLevelCourseModuleItem,
|
id: "m2",
|
||||||
} from "../../types/course.types";
|
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 { AddModuleModal } from "./components/AddModuleModal";
|
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() {
|
export function CourseDetailPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { level: programIdParam, courseId: courseIdParam } = useParams<{
|
const { level, courseId } = useParams<{ level: string; courseId: string }>();
|
||||||
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 [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 (
|
return (
|
||||||
<div className="space-y-10 pb-20 pt-10">
|
<div className="space-y-10 pb-20 pt-10">
|
||||||
{/* Header Navigation */}
|
{/* Header Navigation */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
to={`/new-content/learn-english/${programIdParam}/courses`}
|
to={`/new-content/learn-english/${level}/courses`}
|
||||||
className="flex items-center gap-2 text-sm font-medium text-grayScale-600 transition-colors hover:text-brand-500"
|
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" />
|
<ArrowLeft className="h-5 w-5" />
|
||||||
Back to Courses
|
Back to Levels
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{/* Hero Section */}
|
||||||
<div className="flex flex-col items-center justify-center py-16">
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
) : error && !course ? (
|
<div className="flex items-center gap-4">
|
||||||
<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
|
<Button
|
||||||
type="button"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="mt-4"
|
className="rounded-[6px] border-brand-500 text-brand-500 "
|
||||||
onClick={() => void loadPage()}
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/new-content/learn-english/${level}/courses/add-practice?backTo=modules&courseId=${courseId}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Try again
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<>
|
|
||||||
<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="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>
|
|
||||||
|
|
||||||
<AddModuleModal
|
<AddModuleModal
|
||||||
isOpen={isAddModuleOpen}
|
isOpen={isAddModuleOpen}
|
||||||
onClose={() => setIsAddModuleOpen(false)}
|
onClose={() => setIsAddModuleOpen(false)}
|
||||||
courseId={courseIdNum}
|
/>
|
||||||
onCreated={() => loadPage()}
|
{/* Gradient Divider */}
|
||||||
/>
|
|
||||||
|
|
||||||
<Dialog
|
{/* Gradient Grid */}
|
||||||
open={editingModule !== null}
|
<div className="flex flex-warp gap-10">
|
||||||
onOpenChange={(open) => {
|
{MODULES.map((module) => (
|
||||||
if (!open && savingModuleEdit) return;
|
<Card
|
||||||
if (!open && editModuleIconUploadBusy) return;
|
key={module.id}
|
||||||
if (!open) closeEditModule();
|
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"
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<DialogContent className="max-w-lg">
|
{/* Gradient Banner */}
|
||||||
<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>
|
|
||||||
|
|
||||||
{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
|
<div
|
||||||
className="grid justify-start gap-10"
|
className={cn(
|
||||||
style={{
|
"h-36 w-full bg-gradient-to-b opacity-90 transition-transform duration-700",
|
||||||
gridTemplateColumns: "repeat(auto-fill, minmax(330px, 330px))",
|
module.gradient,
|
||||||
}}
|
)}
|
||||||
>
|
/>
|
||||||
{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="p-2 pb-4 pt-4 flex-1 flex flex-col">
|
||||||
<div className="flex min-h-0 flex-1 gap-4">
|
<div className="flex gap-4 mb-8">
|
||||||
<ModuleIconCircle iconSrc={iconSrc} index={index} />
|
{/* Icon Circle */}
|
||||||
|
<div
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
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`}
|
||||||
<h3 className="text-lg font-bold tracking-tight text-[#0F172A]">
|
>
|
||||||
{module.name}
|
<module.icon
|
||||||
</h3>
|
className={`h-6 w-6 ${module.id === "m2" ? "text-[#64748B]" : "text-brand-500"}`}
|
||||||
<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>
|
||||||
|
|
||||||
<div className="px-6 py-6">
|
{/* Content */}
|
||||||
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
|
<div className="space-y-1">
|
||||||
<Trash2 className="h-5 w-5 text-red-500" />
|
<h3 className="text-lg font-bold text-[#0F172A] tracking-tight">
|
||||||
</div>
|
{module.title}
|
||||||
<p className="text-center text-sm leading-relaxed text-grayScale-600">
|
</h3>
|
||||||
Are you sure you want to delete{" "}
|
<p className="text-grayScale-400 font-medium text-[12px]">
|
||||||
<span className="font-semibold text-grayScale-700">
|
{module.description}
|
||||||
{deletingModule.name}
|
|
||||||
</span>
|
|
||||||
? This cannot be undone. Related content may be affected
|
|
||||||
depending on your backend.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
{/* 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" ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
disabled
|
||||||
variant="outline"
|
className="flex-1 h-10 rounded-[6px] bg-[#D291BC] text-white opacity-100 cursor-default border-none shadow-none text-sm"
|
||||||
onClick={() => setDeletingModule(null)}
|
|
||||||
disabled={deletingModuleInFlight}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
>
|
||||||
Cancel
|
Published
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
) : (
|
||||||
type="button"
|
<Button className="flex-1 h-10 rounded-[6px] bg-brand-500 text-white shadow-md shadow-brand-500/10 text-sm">
|
||||||
className="w-full bg-red-500 shadow-sm transition-all hover:bg-red-600 hover:shadow-md sm:w-auto"
|
Publish Practice
|
||||||
disabled={deletingModuleInFlight}
|
|
||||||
onClick={() => void handleConfirmDeleteModule()}
|
|
||||||
>
|
|
||||||
{deletingModuleInFlight ? "Deleting…" : "Delete"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Card>
|
||||||
</>
|
))}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import {
|
import {
|
||||||
|
BadgeCheck,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
|
|
@ -31,9 +32,9 @@ import { Badge } from "../../components/ui/badge"
|
||||||
import {
|
import {
|
||||||
getCourseCategories,
|
getCourseCategories,
|
||||||
getCoursesByCategory,
|
getCoursesByCategory,
|
||||||
getSubModulesByCourse,
|
getLearningPath,
|
||||||
getVideosBySubModule,
|
|
||||||
getQuestionSetsByOwner,
|
getQuestionSetsByOwner,
|
||||||
|
getSubModuleEntryAssessment,
|
||||||
reorderCategories,
|
reorderCategories,
|
||||||
reorderCourses,
|
reorderCourses,
|
||||||
reorderSubModules,
|
reorderSubModules,
|
||||||
|
|
@ -193,7 +194,9 @@ export function CourseFlowBuilderPage() {
|
||||||
const [practicesBySubCourse, setPracticesBySubCourse] = useState<Record<number, PracticeListItem[]>>(
|
const [practicesBySubCourse, setPracticesBySubCourse] = useState<Record<number, PracticeListItem[]>>(
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
const [videosBySubCourse, setVideosBySubCourse] = useState<Record<number, LearningPathVideo[]>>({})
|
const [entryAssessmentBySubCourse, setEntryAssessmentBySubCourse] = useState<Record<number, QuestionSet | null>>(
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [loadingCourses, setLoadingCourses] = useState(false)
|
const [loadingCourses, setLoadingCourses] = useState(false)
|
||||||
|
|
@ -257,9 +260,7 @@ export function CourseFlowBuilderPage() {
|
||||||
setLoadingCourses(true)
|
setLoadingCourses(true)
|
||||||
try {
|
try {
|
||||||
const res = await getCoursesByCategory(selectedCategoryId)
|
const res = await getCoursesByCategory(selectedCategoryId)
|
||||||
const items = sortByDisplayOrder(
|
const items = sortByDisplayOrder(res.data.data.courses ?? [])
|
||||||
(res.data.data.courses ?? []).filter((course) => Number(course.category_id) === Number(selectedCategoryId)),
|
|
||||||
)
|
|
||||||
setCoursesByCategory((prev) => ({ ...prev, [selectedCategoryId]: items }))
|
setCoursesByCategory((prev) => ({ ...prev, [selectedCategoryId]: items }))
|
||||||
setSelectedCourseId(items[0]?.id ?? null)
|
setSelectedCourseId(items[0]?.id ?? null)
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -279,94 +280,47 @@ export function CourseFlowBuilderPage() {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoadingPath(true)
|
setLoadingPath(true)
|
||||||
try {
|
try {
|
||||||
const selectedCourse = activeCourses.find((course) => course.id === selectedCourseId)
|
const res = await getLearningPath(selectedCourseId)
|
||||||
const subRes = await getSubModulesByCourse(selectedCourseId)
|
const path = res.data.data
|
||||||
const subCourses = sortByDisplayOrder((subRes.data.data.sub_courses ?? []) as any[]).map((sc) => ({
|
|
||||||
id: sc.id,
|
|
||||||
title: sc.title,
|
|
||||||
description: sc.description ?? "",
|
|
||||||
thumbnail: sc.thumbnail ?? "",
|
|
||||||
display_order: sc.display_order ?? 0,
|
|
||||||
level: sc.level ?? sc.cefr_level ?? "",
|
|
||||||
sub_level: sc.sub_level ?? "",
|
|
||||||
prerequisite_count: 0,
|
|
||||||
video_count: 0,
|
|
||||||
practice_count: 0,
|
|
||||||
prerequisites: [],
|
|
||||||
videos: [],
|
|
||||||
practices: [],
|
|
||||||
}))
|
|
||||||
|
|
||||||
setLearningPath({
|
setLearningPath({
|
||||||
course_id: selectedCourseId,
|
...path,
|
||||||
course_title: selectedCourse?.title ?? "",
|
sub_courses: sortByDisplayOrder(path.sub_courses ?? []),
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (subCourses.length === 0) {
|
// Practices source of truth: question sets by SUB_COURSE owner.
|
||||||
setPracticesBySubCourse({})
|
const subCourses = path.sub_courses ?? []
|
||||||
setVideosBySubCourse({})
|
if (subCourses.length > 0) {
|
||||||
return
|
const ownerResults = await Promise.all(
|
||||||
}
|
|
||||||
|
|
||||||
const [ownerResults, videoResults] = await Promise.all([
|
|
||||||
Promise.all(
|
|
||||||
subCourses.map(async (sc) => {
|
subCourses.map(async (sc) => {
|
||||||
const setsRes = await getQuestionSetsByOwner("SUB_MODULE", sc.id)
|
const setsRes = await getQuestionSetsByOwner("SUB_COURSE", sc.id)
|
||||||
return [sc.id, mapPracticeSetsToPracticeItems((setsRes.data.data ?? []) as QuestionSet[])] as const
|
return [sc.id, mapPracticeSetsToPracticeItems((setsRes.data.data ?? []) as QuestionSet[])] as const
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
Promise.all(
|
const practiceMap: Record<number, PracticeListItem[]> = {}
|
||||||
subCourses.map(async (sc) => {
|
ownerResults.forEach(([subCourseId, practiceItems]) => {
|
||||||
const videosRes = await getVideosBySubModule(sc.id)
|
practiceMap[subCourseId] = practiceItems
|
||||||
const rows = videosRes.data?.data?.videos ?? []
|
})
|
||||||
const mapped = sortByDisplayOrder(
|
setPracticesBySubCourse(practiceMap)
|
||||||
rows.map((video: any, idx: number) => ({
|
} else {
|
||||||
id: Number(video.id),
|
setPracticesBySubCourse({})
|
||||||
title: String(video.title ?? "Video"),
|
}
|
||||||
display_order: Number(video.display_order ?? idx),
|
|
||||||
duration: Number(video.duration ?? 0),
|
|
||||||
video_url: String(video.video_url ?? ""),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
return [sc.id, mapped] as const
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
])
|
|
||||||
|
|
||||||
const practiceMap: Record<number, PracticeListItem[]> = {}
|
|
||||||
ownerResults.forEach(([subCourseId, practiceItems]) => {
|
|
||||||
practiceMap[subCourseId] = practiceItems
|
|
||||||
})
|
|
||||||
setPracticesBySubCourse(practiceMap)
|
|
||||||
|
|
||||||
const videoMap: Record<number, LearningPathVideo[]> = {}
|
|
||||||
videoResults.forEach(([subCourseId, videos]) => {
|
|
||||||
videoMap[subCourseId] = videos
|
|
||||||
})
|
|
||||||
setVideosBySubCourse(videoMap)
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to load course flow detail.")
|
toast.error("Failed to load course sub-category learning path.")
|
||||||
setLearningPath(null)
|
setLearningPath(null)
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingPath(false)
|
setLoadingPath(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
}, [selectedCourseId, activeCourses, selectedCategoryId, topLevelCategories])
|
}, [selectedCourseId])
|
||||||
|
|
||||||
const loadSubCoursePracticeAndEntry = async (subCourseId: number) => {
|
const loadSubCoursePracticeAndEntry = async (subCourseId: number) => {
|
||||||
if (practicesBySubCourse[subCourseId] && videosBySubCourse[subCourseId]) return
|
if (practicesBySubCourse[subCourseId] && entryAssessmentBySubCourse[subCourseId] !== undefined) return
|
||||||
setLoadingPracticesBySubCourse((prev) => ({ ...prev, [subCourseId]: true }))
|
setLoadingPracticesBySubCourse((prev) => ({ ...prev, [subCourseId]: true }))
|
||||||
try {
|
try {
|
||||||
const [setsRes, videosRes] = await Promise.allSettled([
|
const [setsRes, entryRes] = await Promise.allSettled([
|
||||||
getQuestionSetsByOwner("SUB_MODULE", subCourseId),
|
getQuestionSetsByOwner("SUB_COURSE", subCourseId),
|
||||||
getVideosBySubModule(subCourseId),
|
getSubModuleEntryAssessment(subCourseId),
|
||||||
])
|
])
|
||||||
|
|
||||||
// No practice sets is a valid empty-state scenario; do not toast for 404/empty.
|
// No practice sets is a valid empty-state scenario; do not toast for 404/empty.
|
||||||
|
|
@ -385,21 +339,20 @@ export function CourseFlowBuilderPage() {
|
||||||
[subCourseId]: mapPracticeSetsToPracticeItems(ownerSets),
|
[subCourseId]: mapPracticeSetsToPracticeItems(ownerSets),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const videos =
|
// Entry assessment may legitimately be absent.
|
||||||
videosRes.status === "fulfilled"
|
let entryAssessment: QuestionSet | null = null
|
||||||
? sortByDisplayOrder(
|
if (entryRes.status === "fulfilled") {
|
||||||
(videosRes.value.data?.data?.videos ?? []).map((video: any, idx: number) => ({
|
entryAssessment = (entryRes.value.data.data ?? null) as QuestionSet | null
|
||||||
id: Number(video.id),
|
} else {
|
||||||
title: String(video.title ?? "Video"),
|
const status = entryRes.reason?.response?.status
|
||||||
display_order: Number(video.display_order ?? idx),
|
if (status !== 404) {
|
||||||
duration: Number(video.duration ?? 0),
|
throw entryRes.reason
|
||||||
video_url: String(video.video_url ?? ""),
|
}
|
||||||
})),
|
}
|
||||||
)
|
|
||||||
: []
|
setEntryAssessmentBySubCourse((prev) => ({
|
||||||
setVideosBySubCourse((prev) => ({
|
|
||||||
...prev,
|
...prev,
|
||||||
[subCourseId]: videos,
|
[subCourseId]: entryAssessment,
|
||||||
}))
|
}))
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to load practice sets for course.")
|
toast.error("Failed to load practice sets for course.")
|
||||||
|
|
@ -741,7 +694,6 @@ export function CourseFlowBuilderPage() {
|
||||||
{learningPath.sub_courses.map((subCourse) => {
|
{learningPath.sub_courses.map((subCourse) => {
|
||||||
const expanded = expandedSubCourseIds.has(subCourse.id)
|
const expanded = expandedSubCourseIds.has(subCourse.id)
|
||||||
const practices = practicesBySubCourse[subCourse.id] ?? []
|
const practices = practicesBySubCourse[subCourse.id] ?? []
|
||||||
const videos = videosBySubCourse[subCourse.id] ?? subCourse.videos ?? []
|
|
||||||
return (
|
return (
|
||||||
<SortableRow key={subCourse.id} id={subCourse.id}>
|
<SortableRow key={subCourse.id} id={subCourse.id}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -771,12 +723,17 @@ export function CourseFlowBuilderPage() {
|
||||||
{subCourse.sub_level}
|
{subCourse.sub_level}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{/* entry-assessment route is no longer guaranteed across deployments */}
|
{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>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-end">
|
<div className="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-end">
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
{videos.length} videos / {practices.length} practices
|
{subCourse.videos.length} videos / {practices.length || subCourse.practice_count} practices
|
||||||
</Badge>
|
</Badge>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<ChevronDown className="h-4 w-4 text-grayScale-400" />
|
<ChevronDown className="h-4 w-4 text-grayScale-400" />
|
||||||
|
|
@ -798,16 +755,16 @@ export function CourseFlowBuilderPage() {
|
||||||
onDragEnd={(event) => onVideosDragEnd(subCourse.id, event)}
|
onDragEnd={(event) => onVideosDragEnd(subCourse.id, event)}
|
||||||
>
|
>
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={videos.map((item) => item.id)}
|
items={subCourse.videos.map((item) => item.id)}
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{videos.length === 0 ? (
|
{subCourse.videos.length === 0 ? (
|
||||||
<p className="rounded-lg border border-dashed border-grayScale-200 px-2 py-2 text-[11px] text-grayScale-400">
|
<p className="rounded-lg border border-dashed border-grayScale-200 px-2 py-2 text-[11px] text-grayScale-400">
|
||||||
No videos
|
No videos
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
videos.map((video) => (
|
subCourse.videos.map((video) => (
|
||||||
<SortableChip
|
<SortableChip
|
||||||
key={video.id}
|
key={video.id}
|
||||||
id={video.id}
|
id={video.id}
|
||||||
|
|
@ -885,7 +842,7 @@ export function CourseFlowBuilderPage() {
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Practices load from <code>/question-sets/by-owner</code> filtered by
|
Practices load from <code>/question-sets/by-owner</code> filtered by
|
||||||
<code> set_type=PRACTICE</code> and <code>owner_type=SUB_MODULE</code>.
|
<code> set_type=PRACTICE</code>; entry assessment loads from dedicated course endpoint.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { Link, useParams, useNavigate } from "react-router-dom";
|
import { Link, useParams, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
|
@ -7,15 +6,12 @@ import {
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Card } from "../../components/ui/card";
|
import { Card } from "../../components/ui/card";
|
||||||
import { Textarea } from "../../components/ui/textarea";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -26,15 +22,6 @@ import {
|
||||||
} from "../../components/ui/dialog";
|
} from "../../components/ui/dialog";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import uploadIcon from "../../assets/icons/upload.png";
|
import uploadIcon from "../../assets/icons/upload.png";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { ResolvedImage } from "../../components/media/ResolvedImage";
|
|
||||||
import {
|
|
||||||
createExamPrepCatalogUnit,
|
|
||||||
updateExamPrepCatalogUnit,
|
|
||||||
deleteExamPrepCatalogUnit,
|
|
||||||
getExamPrepCatalogUnits,
|
|
||||||
} from "../../api/courses.api";
|
|
||||||
import { uploadImageFile } from "../../api/files.api";
|
|
||||||
|
|
||||||
export function CourseManagementPage() {
|
export function CourseManagementPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -42,38 +29,6 @@ export function CourseManagementPage() {
|
||||||
programType: string;
|
programType: string;
|
||||||
courseId: string;
|
courseId: string;
|
||||||
}>();
|
}>();
|
||||||
const catalogCourseId = Number(courseId);
|
|
||||||
const [addUnitOpen, setAddUnitOpen] = useState(false);
|
|
||||||
const [createName, setCreateName] = useState("");
|
|
||||||
const [createDescription, setCreateDescription] = useState("");
|
|
||||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
|
|
||||||
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [units, setUnits] = useState<
|
|
||||||
Array<{
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
thumbnail: string;
|
|
||||||
sortOrder: number;
|
|
||||||
modules: number;
|
|
||||||
lessons: number;
|
|
||||||
practices: number;
|
|
||||||
gradient: string;
|
|
||||||
}>
|
|
||||||
>([]);
|
|
||||||
const [unitsLoading, setUnitsLoading] = useState(false);
|
|
||||||
const [editingUnitId, setEditingUnitId] = useState<number | null>(null);
|
|
||||||
const [editName, setEditName] = useState("");
|
|
||||||
const [editDescription, setEditDescription] = useState("");
|
|
||||||
const [editThumbnail, setEditThumbnail] = useState("");
|
|
||||||
const [editSortOrder, setEditSortOrder] = useState("1");
|
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
|
||||||
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
|
||||||
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [deletingUnitId, setDeletingUnitId] = useState<number | null>(null);
|
|
||||||
const [deletingUnit, setDeletingUnit] = useState(false);
|
|
||||||
|
|
||||||
// Mock data for display titles
|
// Mock data for display titles
|
||||||
const courseTitles: Record<string, string> = {
|
const courseTitles: Record<string, string> = {
|
||||||
|
|
@ -84,289 +39,41 @@ export function CourseManagementPage() {
|
||||||
const courseDisplayName =
|
const courseDisplayName =
|
||||||
courseTitles[courseId || ""] || "Duolingo English Test";
|
courseTitles[courseId || ""] || "Duolingo English Test";
|
||||||
|
|
||||||
const loadUnits = useCallback(async () => {
|
const units = [
|
||||||
if (!Number.isFinite(catalogCourseId) || catalogCourseId < 1) {
|
{
|
||||||
setUnits([]);
|
id: "unit1",
|
||||||
return;
|
name: "Greetings & Introductions",
|
||||||
}
|
description:
|
||||||
setUnitsLoading(true);
|
"Learn basic greetings, self-introductions, and polite expressions in everyday situations.",
|
||||||
try {
|
modules: 3,
|
||||||
const response = await getExamPrepCatalogUnits(catalogCourseId, {
|
videos: 9,
|
||||||
limit: 20,
|
practices: 9,
|
||||||
offset: 0,
|
gradient:
|
||||||
});
|
"linear-gradient(135deg, rgba(158, 40, 145, 0.5) 0%, rgba(158, 40, 145, 0.8) 100%)",
|
||||||
const rows = response.data?.data?.units;
|
},
|
||||||
const list = Array.isArray(rows) ? rows : [];
|
{
|
||||||
setUnits(
|
id: "unit2",
|
||||||
list.map((row, index) => ({
|
name: "Speaking",
|
||||||
id: Number(row.id),
|
description:
|
||||||
name: row.name?.trim() || `Unit ${row.id}`,
|
"Core speaking practice and skill building for natural pronunciation and fluency.",
|
||||||
description: row.description?.trim() || "—",
|
modules: 3,
|
||||||
thumbnail: row.thumbnail?.trim() || "",
|
videos: 9,
|
||||||
sortOrder: Number(row.sort_order ?? 0),
|
practices: 9,
|
||||||
modules: Number(row.modules_count ?? 0),
|
gradient:
|
||||||
lessons: Number(row.lessons_count ?? row.videos_count ?? 0),
|
"linear-gradient(135deg, rgba(79, 70, 229, 0.5) 0%, rgba(79, 70, 229, 0.8) 100%)",
|
||||||
practices: Number(row.practices_count ?? 0),
|
},
|
||||||
gradient:
|
{
|
||||||
index % 3 === 1
|
id: "unit3",
|
||||||
? "linear-gradient(135deg, rgba(79, 70, 229, 0.5) 0%, rgba(79, 70, 229, 0.8) 100%)"
|
name: "Reading",
|
||||||
: index % 3 === 2
|
description:
|
||||||
? "linear-gradient(135deg, rgba(124, 58, 237, 0.5) 0%, rgba(124, 58, 237, 0.8) 100%)"
|
"Reading comprehension and vocabulary improvement through various text types.",
|
||||||
: "linear-gradient(135deg, rgba(158, 40, 145, 0.5) 0%, rgba(158, 40, 145, 0.8) 100%)",
|
modules: 3,
|
||||||
})),
|
videos: 9,
|
||||||
);
|
practices: 9,
|
||||||
} catch (error) {
|
gradient:
|
||||||
console.error(error);
|
"linear-gradient(135deg, rgba(124, 58, 237, 0.5) 0%, rgba(124, 58, 237, 0.8) 100%)",
|
||||||
toast.error("Failed to load units");
|
},
|
||||||
setUnits([]);
|
];
|
||||||
} finally {
|
|
||||||
setUnitsLoading(false);
|
|
||||||
}
|
|
||||||
}, [catalogCourseId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadUnits();
|
|
||||||
}, [loadUnits]);
|
|
||||||
|
|
||||||
const isHttpUrl = (value: string) =>
|
|
||||||
value.startsWith("http://") || value.startsWith("https://");
|
|
||||||
|
|
||||||
const isMinioUrl = (value: string) => {
|
|
||||||
try {
|
|
||||||
const url = new URL(value);
|
|
||||||
return url.host === "s3.yimaruacademy.com";
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveThumbnailToMinioUrl = async (rawValue: string) => {
|
|
||||||
const trimmed = rawValue.trim();
|
|
||||||
if (!trimmed) return "";
|
|
||||||
if (!isHttpUrl(trimmed) || isMinioUrl(trimmed)) return trimmed;
|
|
||||||
const uploaded = await uploadImageFile(trimmed);
|
|
||||||
const uploadedUrl = uploaded.data?.data?.url?.trim();
|
|
||||||
if (!uploadedUrl) throw new Error("Failed to upload thumbnail URL to MinIO");
|
|
||||||
return uploadedUrl;
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearCreateUnitForm = () => {
|
|
||||||
setCreateName("");
|
|
||||||
setCreateDescription("");
|
|
||||||
setCreateThumbnail("");
|
|
||||||
if (createThumbnailFileInputRef.current) {
|
|
||||||
createThumbnailFileInputRef.current.value = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateUnitThumbnailFile = 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;
|
|
||||||
}
|
|
||||||
setUploadingThumbnail(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 (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload thumbnail";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateUnit = async () => {
|
|
||||||
if (!Number.isFinite(catalogCourseId) || catalogCourseId < 1) {
|
|
||||||
toast.error("Invalid catalog course");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const name = createName.trim();
|
|
||||||
if (!name) {
|
|
||||||
toast.error("Unit name is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCreating(true);
|
|
||||||
try {
|
|
||||||
const minioThumbnail = await resolveThumbnailToMinioUrl(createThumbnail);
|
|
||||||
const response = await createExamPrepCatalogUnit(catalogCourseId, {
|
|
||||||
name,
|
|
||||||
description: createDescription.trim() || null,
|
|
||||||
thumbnail: minioThumbnail || null,
|
|
||||||
});
|
|
||||||
void response;
|
|
||||||
await loadUnits();
|
|
||||||
toast.success("Unit created");
|
|
||||||
clearCreateUnitForm();
|
|
||||||
setAddUnitOpen(false);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to create unit";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setCreating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const autoUploadCreateThumbnailUrl = async (rawValue: string) => {
|
|
||||||
const trimmed = rawValue.trim();
|
|
||||||
if (!trimmed || !isHttpUrl(trimmed) || isMinioUrl(trimmed)) return;
|
|
||||||
setUploadingThumbnail(true);
|
|
||||||
try {
|
|
||||||
const minioUrl = await resolveThumbnailToMinioUrl(trimmed);
|
|
||||||
if (minioUrl && minioUrl !== trimmed) {
|
|
||||||
setCreateThumbnail(minioUrl);
|
|
||||||
toast.success("Thumbnail uploaded to MinIO");
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload URL to MinIO";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const autoUploadEditThumbnailUrl = async (rawValue: string) => {
|
|
||||||
const trimmed = rawValue.trim();
|
|
||||||
if (!trimmed || !isHttpUrl(trimmed) || isMinioUrl(trimmed)) return;
|
|
||||||
setUploadingEditThumbnail(true);
|
|
||||||
try {
|
|
||||||
const minioUrl = await resolveThumbnailToMinioUrl(trimmed);
|
|
||||||
if (minioUrl && minioUrl !== trimmed) {
|
|
||||||
setEditThumbnail(minioUrl);
|
|
||||||
toast.success("Thumbnail uploaded to MinIO");
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload URL to MinIO";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingEditThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditUnit = (unit: (typeof units)[number]) => {
|
|
||||||
setEditingUnitId(unit.id);
|
|
||||||
setEditName(unit.name ?? "");
|
|
||||||
setEditDescription(unit.description ?? "");
|
|
||||||
setEditThumbnail(unit.thumbnail ?? "");
|
|
||||||
setEditSortOrder(String(unit.sortOrder ?? 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeEditUnit = () => {
|
|
||||||
if (savingEdit || uploadingEditThumbnail) return;
|
|
||||||
setEditingUnitId(null);
|
|
||||||
setEditName("");
|
|
||||||
setEditDescription("");
|
|
||||||
setEditThumbnail("");
|
|
||||||
setEditSortOrder("1");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditUnitThumbnailFile = 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;
|
|
||||||
}
|
|
||||||
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 (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload thumbnail";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingEditThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveEditUnit = async () => {
|
|
||||||
if (!editingUnitId) return;
|
|
||||||
const name = editName.trim();
|
|
||||||
if (!name) {
|
|
||||||
toast.error("Unit name is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sortOrderNum = Number(editSortOrder);
|
|
||||||
if (!Number.isFinite(sortOrderNum) || sortOrderNum < 0) {
|
|
||||||
toast.error("Sort order must be a valid number");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSavingEdit(true);
|
|
||||||
try {
|
|
||||||
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
|
|
||||||
await updateExamPrepCatalogUnit(editingUnitId, {
|
|
||||||
name,
|
|
||||||
description: editDescription.trim() || null,
|
|
||||||
thumbnail: minioThumbnail || null,
|
|
||||||
sort_order: sortOrderNum,
|
|
||||||
});
|
|
||||||
await loadUnits();
|
|
||||||
toast.success("Unit updated");
|
|
||||||
closeEditUnit();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to update unit";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setSavingEdit(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteUnit = async () => {
|
|
||||||
if (!deletingUnitId) return;
|
|
||||||
setDeletingUnit(true);
|
|
||||||
try {
|
|
||||||
await deleteExamPrepCatalogUnit(deletingUnitId);
|
|
||||||
await loadUnits();
|
|
||||||
toast.success("Unit deleted");
|
|
||||||
setDeletingUnitId(null);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to delete unit";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setDeletingUnit(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
||||||
|
|
@ -391,51 +98,29 @@ export function CourseManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
<div className="flex items-center gap-3 pt-2">
|
||||||
<Dialog
|
<Dialog>
|
||||||
open={addUnitOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open && (creating || uploadingThumbnail)) return;
|
|
||||||
setAddUnitOpen(open);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-sm hover:bg-brand-600 transition-all flex items-center gap-2">
|
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-sm hover:bg-brand-600 transition-all flex items-center gap-2">
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="h-5 w-5" />
|
||||||
Add Unit
|
Add Unit
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
|
<DialogContent className="max-w-[600px] p-0 border-none rounded-[16px] overflow-hidden">
|
||||||
<div className="flex min-h-0 flex-1 flex-col bg-white">
|
<div className="bg-white">
|
||||||
<DialogHeader className="shrink-0 px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
|
<DialogHeader className="px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
|
||||||
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
|
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
|
||||||
Create Unit
|
Create Courses
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-8 space-y-8">
|
<div className="p-8 space-y-8">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-[15px] text-grayScale-800">
|
<label className="text-[15px] text-grayScale-800">
|
||||||
Unit Name
|
Unit Name
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={createName}
|
|
||||||
onChange={(e) => setCreateName(e.target.value)}
|
|
||||||
placeholder="e.g. Reading"
|
placeholder="e.g. Reading"
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={createDescription}
|
|
||||||
onChange={(e) => setCreateDescription(e.target.value)}
|
|
||||||
placeholder="Short unit description"
|
|
||||||
rows={4}
|
|
||||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -443,20 +128,7 @@ export function CourseManagementPage() {
|
||||||
<label className="text-[15px] text-grayScale-800">
|
<label className="text-[15px] text-grayScale-800">
|
||||||
Thumbnail
|
Thumbnail
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className="relative group cursor-pointer">
|
||||||
ref={createThumbnailFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="sr-only"
|
|
||||||
onChange={(e) => void handleCreateUnitThumbnailFile(e)}
|
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative group w-full cursor-pointer"
|
|
||||||
onClick={() => createThumbnailFileInputRef.current?.click()}
|
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all ">
|
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all ">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<img
|
<img
|
||||||
|
|
@ -467,62 +139,31 @@ export function CourseManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[15px]">
|
<p className="text-[15px]">
|
||||||
<span className="text-brand-500 font-bold hover:underline">
|
<span className="text-brand-500 font-bold hover:underline">
|
||||||
{uploadingThumbnail ? "Uploading…" : "Click to upload"}
|
Click to upload
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
<span className="text-grayScale-500">
|
<span className="text-grayScale-500">
|
||||||
or paste a URL below
|
or drag and drop
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
|
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
|
||||||
JPG, PNG (MAX 5 MB)
|
JPG, PNG (MAX 1 MB)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
{createThumbnail.trim() ? (
|
|
||||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
|
||||||
<ResolvedImage
|
|
||||||
src={createThumbnail.trim()}
|
|
||||||
alt=""
|
|
||||||
className="h-28 w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<Input
|
|
||||||
value={createThumbnail}
|
|
||||||
onChange={(e) => setCreateThumbnail(e.target.value)}
|
|
||||||
onPaste={(event) => {
|
|
||||||
const pasted = event.clipboardData?.getData("text")?.trim();
|
|
||||||
if (!pasted) return;
|
|
||||||
setTimeout(() => {
|
|
||||||
void autoUploadCreateThumbnailUrl(pasted);
|
|
||||||
}, 0);
|
|
||||||
}}
|
|
||||||
placeholder="Optional thumbnail URL (or leave empty for null)"
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
<div className="px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
|
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
onClick={clearCreateUnitForm}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<Button
|
<Button className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600">
|
||||||
type="button"
|
Create Courses
|
||||||
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
|
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
onClick={() => void handleCreateUnit()}
|
|
||||||
>
|
|
||||||
{creating ? "Creating..." : "Create Unit"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -559,61 +200,16 @@ export function CourseManagementPage() {
|
||||||
|
|
||||||
{/* Grid of Units */}
|
{/* Grid of Units */}
|
||||||
<div className="flex flex-wrap gap-4 pt-4">
|
<div className="flex flex-wrap gap-4 pt-4">
|
||||||
{unitsLoading ? (
|
{units.map((unit) => (
|
||||||
<p className="text-sm text-grayScale-500">Loading units...</p>
|
|
||||||
) : units.length === 0 ? (
|
|
||||||
<div className="w-full 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 units for this course yet
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
|
||||||
Create your first unit to start organizing modules, lessons, and practices.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
units.map((unit) => (
|
|
||||||
<Card
|
<Card
|
||||||
key={unit.id}
|
key={unit.id}
|
||||||
className="group relative flex w-[400px] flex-col h-full bg-white rounded-[12px] border border-grayScale-100 overflow-hidden shadow-sm hover:shadow-md transition-all"
|
className="group flex w-[400px] flex-col h-full bg-white rounded-[12px] border border-grayScale-100 overflow-hidden shadow-sm hover:shadow-md transition-all"
|
||||||
>
|
>
|
||||||
<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"
|
|
||||||
onClick={() => openEditUnit(unit)}
|
|
||||||
aria-label={`Edit ${unit.name}`}
|
|
||||||
>
|
|
||||||
<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"
|
|
||||||
onClick={() => setDeletingUnitId(unit.id)}
|
|
||||||
aria-label={`Delete ${unit.name}`}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/* Gradient Header */}
|
{/* Gradient Header */}
|
||||||
<div
|
<div
|
||||||
className="relative h-36 w-full overflow-hidden transition-transform duration-500"
|
className="h-36 w-full transition-transform duration-500 "
|
||||||
style={{ background: unit.gradient }}
|
style={{ background: unit.gradient }}
|
||||||
>
|
/>
|
||||||
{unit.thumbnail ? (
|
|
||||||
<ResolvedImage
|
|
||||||
src={unit.thumbnail}
|
|
||||||
alt={`${unit.name} thumbnail`}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
onError={(event) => {
|
|
||||||
event.currentTarget.style.display = "none";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 flex flex-col flex-1 space-y-6">
|
<div className="p-4 flex flex-col flex-1 space-y-6">
|
||||||
<div className="space-y-3 flex-1">
|
<div className="space-y-3 flex-1">
|
||||||
|
|
@ -636,7 +232,7 @@ export function CourseManagementPage() {
|
||||||
<div className="h-9 px-3 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-600">
|
<div className="h-9 px-3 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-600">
|
||||||
<PlayCircle className="h-3.5 w-3.5 text-grayScale-400" />
|
<PlayCircle className="h-3.5 w-3.5 text-grayScale-400" />
|
||||||
<span className="text-[12px] font-bold">
|
<span className="text-[12px] font-bold">
|
||||||
{unit.lessons} Lessons
|
{unit.videos} Videos
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-9 px-3 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-600">
|
<div className="h-9 px-3 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-600">
|
||||||
|
|
@ -661,169 +257,8 @@ export function CourseManagementPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={editingUnitId !== null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open && (savingEdit || uploadingEditThumbnail)) return;
|
|
||||||
if (!open) closeEditUnit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col bg-white">
|
|
||||||
<DialogHeader className="shrink-0 px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
|
|
||||||
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
|
|
||||||
Edit Unit
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-8 space-y-8">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Unit Name</label>
|
|
||||||
<Input
|
|
||||||
value={editName}
|
|
||||||
onChange={(e) => setEditName(e.target.value)}
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Description</label>
|
|
||||||
<Textarea
|
|
||||||
value={editDescription}
|
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
|
||||||
rows={4}
|
|
||||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Sort Order</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={editSortOrder}
|
|
||||||
onChange={(e) => setEditSortOrder(e.target.value)}
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
|
|
||||||
<input
|
|
||||||
ref={editThumbnailFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="sr-only"
|
|
||||||
onChange={(e) => void handleEditUnitThumbnailFile(e)}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative group w-full cursor-pointer"
|
|
||||||
onClick={() => editThumbnailFileInputRef.current?.click()}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all ">
|
|
||||||
<div className="mb-4">
|
|
||||||
<img src={uploadIcon} alt="Upload icon" className="h-10 w-10" />
|
|
||||||
</div>
|
|
||||||
<p className="text-[15px]">
|
|
||||||
<span className="text-brand-500 font-bold hover:underline">
|
|
||||||
{uploadingEditThumbnail ? "Uploading…" : "Click to upload"}
|
|
||||||
</span>{" "}
|
|
||||||
<span className="text-grayScale-500">or paste a URL below</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
|
|
||||||
JPG, PNG (MAX 5 MB)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{editThumbnail.trim() ? (
|
|
||||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
|
||||||
<ResolvedImage
|
|
||||||
src={editThumbnail.trim()}
|
|
||||||
alt=""
|
|
||||||
className="h-28 w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<Input
|
|
||||||
value={editThumbnail}
|
|
||||||
onChange={(e) => setEditThumbnail(e.target.value)}
|
|
||||||
onPaste={(event) => {
|
|
||||||
const pasted = event.clipboardData?.getData("text")?.trim();
|
|
||||||
if (!pasted) return;
|
|
||||||
setTimeout(() => {
|
|
||||||
void autoUploadEditThumbnailUrl(pasted);
|
|
||||||
}, 0);
|
|
||||||
}}
|
|
||||||
placeholder="Optional thumbnail URL (or leave empty for null)"
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
onClick={closeEditUnit}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
onClick={() => void handleSaveEditUnit()}
|
|
||||||
>
|
|
||||||
{savingEdit ? "Saving..." : "Save Changes"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={deletingUnitId !== null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open && !deletingUnit) setDeletingUnitId(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-w-md rounded-[16px] border-none p-0 overflow-hidden">
|
|
||||||
<div className="bg-white">
|
|
||||||
<DialogHeader className="border-b border-grayScale-100 px-6 py-5">
|
|
||||||
<DialogTitle className="text-lg font-bold text-grayScale-900">
|
|
||||||
Delete Unit
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="px-6 py-6 text-sm text-grayScale-600">
|
|
||||||
Are you sure you want to delete this unit? This action cannot be undone.
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 border-t border-grayScale-100 px-6 py-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDeletingUnitId(null)}
|
|
||||||
disabled={deletingUnit}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="bg-red-500 hover:bg-red-600"
|
|
||||||
onClick={() => void handleDeleteUnit()}
|
|
||||||
disabled={deletingUnit}
|
|
||||||
>
|
|
||||||
{deletingUnit ? "Deleting..." : "Delete"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,40 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import { ArrowLeft, Plus, FileText, Pencil, Trash2 } from "lucide-react";
|
import { ArrowLeft, Plus, FileText, MoreVertical, Edit2 } from "lucide-react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { Card } from "../../components/ui/card";
|
import { Card } from "../../components/ui/card";
|
||||||
import {
|
|
||||||
Dialog,
|
const MOCK_VIDEOS = [
|
||||||
DialogContent,
|
{
|
||||||
DialogHeader,
|
id: "v1",
|
||||||
DialogTitle,
|
title: "1.1 Introduction to Formal Greetings",
|
||||||
DialogTrigger,
|
duration: "08:45",
|
||||||
DialogClose,
|
status: "Draft",
|
||||||
} from "../../components/ui/dialog";
|
thumbnailColor: "bg-[#CBD5E1]",
|
||||||
import { Input } from "../../components/ui/input";
|
},
|
||||||
import { Textarea } from "../../components/ui/textarea";
|
{
|
||||||
import uploadIcon from "../../assets/icons/upload.png";
|
id: "v2",
|
||||||
import { toast } from "sonner";
|
title: "1.2 Understanding Email Structure",
|
||||||
import { ResolvedImage } from "../../components/media/ResolvedImage";
|
duration: "08:45",
|
||||||
import { VideoCard } from "./components/VideoCard";
|
status: "Published",
|
||||||
import {
|
thumbnailColor: "bg-[#DBEAFE]",
|
||||||
createExamPrepModuleLesson,
|
},
|
||||||
updateExamPrepModuleLesson,
|
{
|
||||||
deleteExamPrepModuleLesson,
|
id: "v3",
|
||||||
getExamPrepModuleLessons,
|
title: "1.3 Common Business Idioms",
|
||||||
} from "../../api/courses.api";
|
duration: "08:45",
|
||||||
import { uploadImageFile, uploadVideoFile } from "../../api/files.api";
|
status: "Published",
|
||||||
|
thumbnailColor: "bg-[#FEF3C7]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "v4",
|
||||||
|
title: "1.4 Video Conference Etiquette",
|
||||||
|
duration: "08:45",
|
||||||
|
status: "Published",
|
||||||
|
thumbnailColor: "bg-[#FCE7F3]",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const MOCK_PRACTICES = [
|
const MOCK_PRACTICES = [
|
||||||
{
|
{
|
||||||
|
|
@ -51,402 +61,19 @@ export function CourseModuleDetailPage() {
|
||||||
unitId: string;
|
unitId: string;
|
||||||
moduleId: string;
|
moduleId: string;
|
||||||
}>();
|
}>();
|
||||||
const parsedModuleId = Number(moduleId);
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
|
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
|
||||||
const [lessonsLoading, setLessonsLoading] = useState(false);
|
const [activeFilter, setActiveFilter] = useState("All");
|
||||||
const [lessons, setLessons] = useState<
|
|
||||||
Array<{
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
videoUrl: string;
|
|
||||||
description: string;
|
|
||||||
thumbnail: string;
|
|
||||||
sortOrder: number;
|
|
||||||
gradient: string;
|
|
||||||
}>
|
|
||||||
>([]);
|
|
||||||
const [createLessonOpen, setCreateLessonOpen] = useState(false);
|
|
||||||
const [createTitle, setCreateTitle] = useState("");
|
|
||||||
const [createVideoUrl, setCreateVideoUrl] = useState("");
|
|
||||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
|
||||||
const [createDescription, setCreateDescription] = useState("");
|
|
||||||
const [creatingLesson, setCreatingLesson] = useState(false);
|
|
||||||
const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
|
|
||||||
const [uploadingVideo, setUploadingVideo] = useState(false);
|
|
||||||
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const createVideoFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [editingLessonId, setEditingLessonId] = useState<number | null>(null);
|
|
||||||
const [editTitle, setEditTitle] = useState("");
|
|
||||||
const [editVideoUrl, setEditVideoUrl] = useState("");
|
|
||||||
const [editThumbnail, setEditThumbnail] = useState("");
|
|
||||||
const [editDescription, setEditDescription] = useState("");
|
|
||||||
const [editSortOrder, setEditSortOrder] = useState("1");
|
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
|
||||||
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
|
||||||
const [uploadingEditVideo, setUploadingEditVideo] = useState(false);
|
|
||||||
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const editVideoFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [deletingLessonId, setDeletingLessonId] = useState<number | null>(null);
|
|
||||||
const [deletingLesson, setDeletingLesson] = useState(false);
|
|
||||||
|
|
||||||
const moduleTitle = "Module 1: Basic Phrases";
|
const moduleTitle = "Module 1: Basic Phrases";
|
||||||
const moduleDescription = "Learn essential phrases for daily conversations.";
|
const moduleDescription = "Learn essential phrases for daily conversations.";
|
||||||
|
|
||||||
const isHttpUrl = (value: string) =>
|
const content = activeTab === "video" ? MOCK_VIDEOS : MOCK_PRACTICES;
|
||||||
value.startsWith("http://") || value.startsWith("https://");
|
const filteredContent = content.filter((item) => {
|
||||||
|
if (activeFilter === "All") return true;
|
||||||
const isMinioUrl = (value: string) => {
|
if (activeFilter === "Drafts") return item.status === "Draft";
|
||||||
try {
|
return item.status === activeFilter;
|
||||||
const url = new URL(value);
|
});
|
||||||
return url.host === "s3.yimaruacademy.com";
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveThumbnailToMinioUrl = async (rawValue: string) => {
|
|
||||||
const trimmed = rawValue.trim();
|
|
||||||
if (!trimmed) return "";
|
|
||||||
if (!isHttpUrl(trimmed) || isMinioUrl(trimmed)) return trimmed;
|
|
||||||
const uploaded = await uploadImageFile(trimmed);
|
|
||||||
const uploadedUrl = uploaded.data?.data?.url?.trim();
|
|
||||||
if (!uploadedUrl) throw new Error("Failed to upload thumbnail URL to MinIO");
|
|
||||||
return uploadedUrl;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadLessons = useCallback(async () => {
|
|
||||||
if (!Number.isFinite(parsedModuleId) || parsedModuleId < 1) {
|
|
||||||
setLessons([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLessonsLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await getExamPrepModuleLessons(parsedModuleId, {
|
|
||||||
limit: 20,
|
|
||||||
offset: 0,
|
|
||||||
});
|
|
||||||
const rows = response.data?.data?.lessons;
|
|
||||||
const list = Array.isArray(rows) ? rows : [];
|
|
||||||
setLessons(
|
|
||||||
list.map((row, index) => ({
|
|
||||||
id: Number(row.id),
|
|
||||||
title: row.title?.trim() || `Lesson ${row.id}`,
|
|
||||||
videoUrl: row.video_url?.trim() || "",
|
|
||||||
description: row.description?.trim() || "—",
|
|
||||||
thumbnail: row.thumbnail?.trim() || "",
|
|
||||||
sortOrder: Number(row.sort_order ?? 0),
|
|
||||||
gradient:
|
|
||||||
index % 3 === 1
|
|
||||||
? "linear-gradient(135deg, rgba(79, 70, 229, 0.35) 0%, rgba(79, 70, 229, 0.6) 100%)"
|
|
||||||
: index % 3 === 2
|
|
||||||
? "linear-gradient(135deg, rgba(124, 58, 237, 0.35) 0%, rgba(124, 58, 237, 0.6) 100%)"
|
|
||||||
: "linear-gradient(135deg, rgba(158, 40, 145, 0.35) 0%, rgba(158, 40, 145, 0.6) 100%)",
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
toast.error("Failed to load lessons");
|
|
||||||
setLessons([]);
|
|
||||||
} finally {
|
|
||||||
setLessonsLoading(false);
|
|
||||||
}
|
|
||||||
}, [parsedModuleId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab !== "video") return;
|
|
||||||
void loadLessons();
|
|
||||||
}, [activeTab, loadLessons]);
|
|
||||||
|
|
||||||
const clearCreateLessonForm = () => {
|
|
||||||
setCreateTitle("");
|
|
||||||
setCreateVideoUrl("");
|
|
||||||
setCreateThumbnail("");
|
|
||||||
setCreateDescription("");
|
|
||||||
if (createThumbnailFileInputRef.current) {
|
|
||||||
createThumbnailFileInputRef.current.value = "";
|
|
||||||
}
|
|
||||||
if (createVideoFileInputRef.current) {
|
|
||||||
createVideoFileInputRef.current.value = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateLessonVideoFile = async (
|
|
||||||
event: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
event.target.value = "";
|
|
||||||
if (!file) return;
|
|
||||||
if (!file.type.startsWith("video/")) {
|
|
||||||
toast.error("Please choose a video file");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUploadingVideo(true);
|
|
||||||
try {
|
|
||||||
const res = await uploadVideoFile(file, {
|
|
||||||
title: createTitle.trim() || "Lesson video",
|
|
||||||
description: createDescription.trim() || undefined,
|
|
||||||
});
|
|
||||||
const finalUrl =
|
|
||||||
res.data?.data?.url?.trim() || res.data?.data?.embed_url?.trim() || "";
|
|
||||||
if (!finalUrl) throw new Error("Upload did not return a video URL");
|
|
||||||
setCreateVideoUrl(finalUrl);
|
|
||||||
toast.success("Video uploaded");
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload video";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingVideo(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateLessonThumbnailFile = 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;
|
|
||||||
}
|
|
||||||
setUploadingThumbnail(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 (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload thumbnail";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const autoUploadCreateThumbnailUrl = async (rawValue: string) => {
|
|
||||||
const trimmed = rawValue.trim();
|
|
||||||
if (!trimmed || !isHttpUrl(trimmed) || isMinioUrl(trimmed)) return;
|
|
||||||
setUploadingThumbnail(true);
|
|
||||||
try {
|
|
||||||
const minioUrl = await resolveThumbnailToMinioUrl(trimmed);
|
|
||||||
if (minioUrl && minioUrl !== trimmed) {
|
|
||||||
setCreateThumbnail(minioUrl);
|
|
||||||
toast.success("Thumbnail uploaded to MinIO");
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload URL to MinIO";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateLesson = async () => {
|
|
||||||
if (!Number.isFinite(parsedModuleId) || parsedModuleId < 1) {
|
|
||||||
toast.error("Invalid module");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const title = createTitle.trim();
|
|
||||||
const videoUrl = createVideoUrl.trim();
|
|
||||||
if (!title) {
|
|
||||||
toast.error("Lesson title is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!videoUrl) {
|
|
||||||
toast.error("Video URL is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCreatingLesson(true);
|
|
||||||
try {
|
|
||||||
const minioThumbnail = await resolveThumbnailToMinioUrl(createThumbnail);
|
|
||||||
await createExamPrepModuleLesson(parsedModuleId, {
|
|
||||||
title,
|
|
||||||
video_url: videoUrl,
|
|
||||||
thumbnail: minioThumbnail || null,
|
|
||||||
description: createDescription.trim() || null,
|
|
||||||
});
|
|
||||||
await loadLessons();
|
|
||||||
toast.success("Lesson created");
|
|
||||||
clearCreateLessonForm();
|
|
||||||
setCreateLessonOpen(false);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to create lesson";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setCreatingLesson(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditLesson = (lesson: (typeof lessons)[number]) => {
|
|
||||||
setEditingLessonId(lesson.id);
|
|
||||||
setEditTitle(lesson.title ?? "");
|
|
||||||
setEditVideoUrl(lesson.videoUrl ?? "");
|
|
||||||
setEditThumbnail(lesson.thumbnail ?? "");
|
|
||||||
setEditDescription(lesson.description ?? "");
|
|
||||||
setEditSortOrder(String(lesson.sortOrder ?? 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeEditLesson = () => {
|
|
||||||
if (savingEdit || uploadingEditThumbnail || uploadingEditVideo) return;
|
|
||||||
setEditingLessonId(null);
|
|
||||||
setEditTitle("");
|
|
||||||
setEditVideoUrl("");
|
|
||||||
setEditThumbnail("");
|
|
||||||
setEditDescription("");
|
|
||||||
setEditSortOrder("1");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditLessonVideoFile = async (
|
|
||||||
event: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
event.target.value = "";
|
|
||||||
if (!file) return;
|
|
||||||
if (!file.type.startsWith("video/")) {
|
|
||||||
toast.error("Please choose a video file");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUploadingEditVideo(true);
|
|
||||||
try {
|
|
||||||
const res = await uploadVideoFile(file, {
|
|
||||||
title: editTitle.trim() || "Lesson video",
|
|
||||||
description: editDescription.trim() || undefined,
|
|
||||||
});
|
|
||||||
const finalUrl =
|
|
||||||
res.data?.data?.url?.trim() || res.data?.data?.embed_url?.trim() || "";
|
|
||||||
if (!finalUrl) throw new Error("Upload did not return a video URL");
|
|
||||||
setEditVideoUrl(finalUrl);
|
|
||||||
toast.success("Video uploaded");
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload video";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingEditVideo(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditLessonThumbnailFile = 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;
|
|
||||||
}
|
|
||||||
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 (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload thumbnail";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingEditThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const autoUploadEditThumbnailUrl = async (rawValue: string) => {
|
|
||||||
const trimmed = rawValue.trim();
|
|
||||||
if (!trimmed || !isHttpUrl(trimmed) || isMinioUrl(trimmed)) return;
|
|
||||||
setUploadingEditThumbnail(true);
|
|
||||||
try {
|
|
||||||
const minioUrl = await resolveThumbnailToMinioUrl(trimmed);
|
|
||||||
if (minioUrl && minioUrl !== trimmed) {
|
|
||||||
setEditThumbnail(minioUrl);
|
|
||||||
toast.success("Thumbnail uploaded to MinIO");
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload URL to MinIO";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingEditThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveEditLesson = async () => {
|
|
||||||
if (!editingLessonId) return;
|
|
||||||
const title = editTitle.trim();
|
|
||||||
if (!title) {
|
|
||||||
toast.error("Lesson title is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sortOrderNum = Number(editSortOrder);
|
|
||||||
if (!Number.isFinite(sortOrderNum) || sortOrderNum < 0) {
|
|
||||||
toast.error("Sort order must be a valid number");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSavingEdit(true);
|
|
||||||
try {
|
|
||||||
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
|
|
||||||
await updateExamPrepModuleLesson(editingLessonId, {
|
|
||||||
title,
|
|
||||||
video_url: editVideoUrl.trim() || null,
|
|
||||||
thumbnail: minioThumbnail || null,
|
|
||||||
description: editDescription.trim() || null,
|
|
||||||
sort_order: sortOrderNum,
|
|
||||||
});
|
|
||||||
await loadLessons();
|
|
||||||
toast.success("Lesson updated");
|
|
||||||
closeEditLesson();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to update lesson";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setSavingEdit(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteLesson = async () => {
|
|
||||||
if (!deletingLessonId) return;
|
|
||||||
setDeletingLesson(true);
|
|
||||||
try {
|
|
||||||
await deleteExamPrepModuleLesson(deletingLessonId);
|
|
||||||
await loadLessons();
|
|
||||||
toast.success("Lesson deleted");
|
|
||||||
setDeletingLessonId(null);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to delete lesson";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setDeletingLesson(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
||||||
|
|
@ -483,188 +110,10 @@ export function CourseModuleDetailPage() {
|
||||||
<FileText className="h-5 w-5" />
|
<FileText className="h-5 w-5" />
|
||||||
Attach Practice
|
Attach Practice
|
||||||
</Button>
|
</Button>
|
||||||
<Dialog
|
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-md hover:bg-brand-600 transition-all flex items-center gap-2 text-[15px]">
|
||||||
open={createLessonOpen}
|
<Plus className="h-5 w-5" />
|
||||||
onOpenChange={(open) => {
|
Add Video
|
||||||
if (!open && (creatingLesson || uploadingThumbnail || uploadingVideo))
|
</Button>
|
||||||
return;
|
|
||||||
setCreateLessonOpen(open);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-md hover:bg-brand-600 transition-all flex items-center gap-2 text-[15px]">
|
|
||||||
<Plus className="h-5 w-5" />
|
|
||||||
Add Lesson
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col bg-white">
|
|
||||||
<DialogHeader className="shrink-0 px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
|
|
||||||
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
|
|
||||||
Create Lesson
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-8 space-y-8">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">
|
|
||||||
Lesson Title
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={createTitle}
|
|
||||||
onChange={(e) => setCreateTitle(e.target.value)}
|
|
||||||
placeholder="e.g. Intro lesson"
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
|
||||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">
|
|
||||||
Video URL
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={createVideoFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="video/*"
|
|
||||||
className="sr-only"
|
|
||||||
onChange={(e) => void handleCreateLessonVideoFile(e)}
|
|
||||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative group w-full cursor-pointer"
|
|
||||||
onClick={() => createVideoFileInputRef.current?.click()}
|
|
||||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all">
|
|
||||||
<div className="mb-4">
|
|
||||||
<img
|
|
||||||
src={uploadIcon}
|
|
||||||
alt="Upload icon"
|
|
||||||
className="h-10 w-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-[15px]">
|
|
||||||
<span className="text-brand-500 font-bold hover:underline">
|
|
||||||
{uploadingVideo ? "Uploading…" : "Click to upload"}
|
|
||||||
</span>{" "}
|
|
||||||
<span className="text-grayScale-500">
|
|
||||||
video from your computer
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
|
|
||||||
MP4, MOV, WEBM
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<Input
|
|
||||||
value={createVideoUrl}
|
|
||||||
onChange={(e) => setCreateVideoUrl(e.target.value)}
|
|
||||||
placeholder="https://example.com/video"
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
|
||||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={createDescription}
|
|
||||||
onChange={(e) => setCreateDescription(e.target.value)}
|
|
||||||
placeholder="Optional lesson description"
|
|
||||||
rows={4}
|
|
||||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
|
||||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">
|
|
||||||
Thumbnail
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={createThumbnailFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="sr-only"
|
|
||||||
onChange={(e) => void handleCreateLessonThumbnailFile(e)}
|
|
||||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative group w-full cursor-pointer"
|
|
||||||
onClick={() => createThumbnailFileInputRef.current?.click()}
|
|
||||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all">
|
|
||||||
<div className="mb-4">
|
|
||||||
<img
|
|
||||||
src={uploadIcon}
|
|
||||||
alt="Upload icon"
|
|
||||||
className="h-10 w-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-[15px]">
|
|
||||||
<span className="text-brand-500 font-bold hover:underline">
|
|
||||||
{uploadingThumbnail ? "Uploading…" : "Click to upload"}
|
|
||||||
</span>{" "}
|
|
||||||
<span className="text-grayScale-500">
|
|
||||||
or paste a URL below
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
|
|
||||||
JPG, PNG (MAX 5 MB)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{createThumbnail.trim() ? (
|
|
||||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
|
||||||
<ResolvedImage
|
|
||||||
src={createThumbnail.trim()}
|
|
||||||
alt=""
|
|
||||||
className="h-28 w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<Input
|
|
||||||
value={createThumbnail}
|
|
||||||
onChange={(e) => setCreateThumbnail(e.target.value)}
|
|
||||||
onPaste={(event) => {
|
|
||||||
const pasted = event.clipboardData?.getData("text")?.trim();
|
|
||||||
if (!pasted) return;
|
|
||||||
setTimeout(() => {
|
|
||||||
void autoUploadCreateThumbnailUrl(pasted);
|
|
||||||
}, 0);
|
|
||||||
}}
|
|
||||||
placeholder="Optional thumbnail URL (or leave empty for null)"
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
|
||||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
|
|
||||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
|
||||||
onClick={clearCreateLessonForm}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
|
|
||||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
|
||||||
onClick={() => void handleCreateLesson()}
|
|
||||||
>
|
|
||||||
{creatingLesson ? "Creating..." : "Create Lesson"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -679,7 +128,7 @@ export function CourseModuleDetailPage() {
|
||||||
: "text-grayScale-400 hover:text-grayScale-600",
|
: "text-grayScale-400 hover:text-grayScale-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Lesson
|
Video
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("practice")}
|
onClick={() => setActiveTab("practice")}
|
||||||
|
|
@ -694,245 +143,40 @@ export function CourseModuleDetailPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid of Content */}
|
{/* Filter Bar */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 pt-4">
|
<div className="bg-white border border-grayScale-100 rounded-[16px] p-4 flex items-center gap-8 shadow-sm">
|
||||||
{activeTab === "video" ? (
|
<div className="text-[12px] font-bold text-grayScale-300 uppercase tracking-widest pl-4">
|
||||||
lessonsLoading ? (
|
STATUS:
|
||||||
<p className="text-sm text-grayScale-500">Loading lessons...</p>
|
</div>
|
||||||
) : lessons.length === 0 ? (
|
<div className="flex items-center gap-2">
|
||||||
<div className="col-span-full rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
|
{["All", "Published", "Drafts", "Archived"].map((filter) => (
|
||||||
<p className="text-sm font-medium text-grayScale-600">
|
<button
|
||||||
No lessons for this module yet
|
key={filter}
|
||||||
</p>
|
onClick={() => setActiveFilter(filter)}
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
className={cn(
|
||||||
Create your first lesson to start building this module.
|
"px-5 py-2 rounded-full text-[13px] font-bold transition-all",
|
||||||
</p>
|
activeFilter === filter
|
||||||
</div>
|
? "bg-brand-500 text-white shadow-md shadow-brand-500/20"
|
||||||
) : (
|
: "bg-grayScale-100 text-grayScale-500 hover:bg-grayScale-200",
|
||||||
lessons.map((lesson) => (
|
)}
|
||||||
<VideoCard
|
>
|
||||||
key={lesson.id}
|
{filter}
|
||||||
title={lesson.title}
|
</button>
|
||||||
thumbnailUrl={lesson.thumbnail}
|
))}
|
||||||
videoUrl={lesson.videoUrl}
|
</div>
|
||||||
thumbnailGradient={lesson.gradient}
|
|
||||||
hoverModuleActions
|
|
||||||
onEdit={() => openEditLesson(lesson)}
|
|
||||||
onDelete={() => setDeletingLessonId(lesson.id)}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
MOCK_PRACTICES.map((item) => <PracticeCard key={item.id} {...item} />)
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
{/* Grid of Content */}
|
||||||
open={editingLessonId !== null}
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 pt-4">
|
||||||
onOpenChange={(open) => {
|
{filteredContent.map((item) => (
|
||||||
if (!open && (savingEdit || uploadingEditThumbnail || uploadingEditVideo))
|
<ContentCard key={item.id} {...item} />
|
||||||
return;
|
))}
|
||||||
if (!open) closeEditLesson();
|
</div>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col bg-white">
|
|
||||||
<DialogHeader className="shrink-0 px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
|
|
||||||
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
|
|
||||||
Edit Lesson
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-8 space-y-8">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Lesson Title</label>
|
|
||||||
<Input
|
|
||||||
value={editTitle}
|
|
||||||
onChange={(e) => setEditTitle(e.target.value)}
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Video URL</label>
|
|
||||||
<input
|
|
||||||
ref={editVideoFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="video/*"
|
|
||||||
className="sr-only"
|
|
||||||
onChange={(e) => void handleEditLessonVideoFile(e)}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative group w-full cursor-pointer"
|
|
||||||
onClick={() => editVideoFileInputRef.current?.click()}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all">
|
|
||||||
<div className="mb-4">
|
|
||||||
<img src={uploadIcon} alt="Upload icon" className="h-10 w-10" />
|
|
||||||
</div>
|
|
||||||
<p className="text-[15px]">
|
|
||||||
<span className="text-brand-500 font-bold hover:underline">
|
|
||||||
{uploadingEditVideo ? "Uploading…" : "Click to upload"}
|
|
||||||
</span>{" "}
|
|
||||||
<span className="text-grayScale-500">
|
|
||||||
video from your computer
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
|
|
||||||
MP4, MOV, WEBM
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<Input
|
|
||||||
value={editVideoUrl}
|
|
||||||
onChange={(e) => setEditVideoUrl(e.target.value)}
|
|
||||||
placeholder="https://example.com/video"
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Description</label>
|
|
||||||
<Textarea
|
|
||||||
value={editDescription}
|
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
|
||||||
rows={4}
|
|
||||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Sort Order</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={editSortOrder}
|
|
||||||
onChange={(e) => setEditSortOrder(e.target.value)}
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
|
|
||||||
<input
|
|
||||||
ref={editThumbnailFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="sr-only"
|
|
||||||
onChange={(e) => void handleEditLessonThumbnailFile(e)}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative group w-full cursor-pointer"
|
|
||||||
onClick={() => editThumbnailFileInputRef.current?.click()}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all">
|
|
||||||
<div className="mb-4">
|
|
||||||
<img src={uploadIcon} alt="Upload icon" className="h-10 w-10" />
|
|
||||||
</div>
|
|
||||||
<p className="text-[15px]">
|
|
||||||
<span className="text-brand-500 font-bold hover:underline">
|
|
||||||
{uploadingEditThumbnail ? "Uploading…" : "Click to upload"}
|
|
||||||
</span>{" "}
|
|
||||||
<span className="text-grayScale-500">or paste a URL below</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
|
|
||||||
JPG, PNG (MAX 5 MB)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{editThumbnail.trim() ? (
|
|
||||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
|
||||||
<ResolvedImage
|
|
||||||
src={editThumbnail.trim()}
|
|
||||||
alt=""
|
|
||||||
className="h-28 w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<Input
|
|
||||||
value={editThumbnail}
|
|
||||||
onChange={(e) => setEditThumbnail(e.target.value)}
|
|
||||||
onPaste={(event) => {
|
|
||||||
const pasted = event.clipboardData?.getData("text")?.trim();
|
|
||||||
if (!pasted) return;
|
|
||||||
setTimeout(() => {
|
|
||||||
void autoUploadEditThumbnailUrl(pasted);
|
|
||||||
}, 0);
|
|
||||||
}}
|
|
||||||
placeholder="Optional thumbnail URL (or leave empty for null)"
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
|
|
||||||
onClick={closeEditLesson}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
|
|
||||||
onClick={() => void handleSaveEditLesson()}
|
|
||||||
>
|
|
||||||
{savingEdit ? "Saving..." : "Save Changes"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={deletingLessonId !== null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open && !deletingLesson) setDeletingLessonId(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-w-md rounded-[16px] border-none p-0 overflow-hidden">
|
|
||||||
<div className="bg-white">
|
|
||||||
<DialogHeader className="border-b border-grayScale-100 px-6 py-5">
|
|
||||||
<DialogTitle className="text-lg font-bold text-grayScale-900">
|
|
||||||
Delete Lesson
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="px-6 py-6 text-sm text-grayScale-600">
|
|
||||||
Are you sure you want to delete this lesson? This action cannot be undone.
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 border-t border-grayScale-100 px-6 py-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDeletingLessonId(null)}
|
|
||||||
disabled={deletingLesson}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="bg-red-500 hover:bg-red-600"
|
|
||||||
onClick={() => void handleDeleteLesson()}
|
|
||||||
disabled={deletingLesson}
|
|
||||||
>
|
|
||||||
{deletingLesson ? "Deleting..." : "Delete"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PracticeCard({
|
function ContentCard({
|
||||||
title,
|
title,
|
||||||
duration,
|
duration,
|
||||||
status,
|
status,
|
||||||
|
|
@ -970,7 +214,9 @@ function PracticeCard({
|
||||||
/>
|
/>
|
||||||
{status}
|
{status}
|
||||||
</div>
|
</div>
|
||||||
<div />
|
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-300 hover:text-grayScale-600 transition-colors">
|
||||||
|
<MoreVertical className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-[14px] font-bold text-[#0F172A] line-clamp-2 leading-snug">
|
<h3 className="text-[14px] font-bold text-[#0F172A] line-clamp-2 leading-snug">
|
||||||
|
|
@ -978,7 +224,11 @@ function PracticeCard({
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="pt-2 grid grid-cols-1 gap-2 mt-auto">
|
<div className="pt-2 grid grid-cols-1 gap-2 mt-auto">
|
||||||
<Button variant="outline" className="w-full h-10 rounded-[10px] border-grayScale-200 text-grayScale-600 font-bold text-xs">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full h-10 rounded-[10px] border-grayScale-200 text-grayScale-600 font-bold flex items-center justify-center gap-2 text-xs hover:bg-grayScale-25"
|
||||||
|
>
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -996,4 +246,3 @@ function PracticeCard({
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2262
src/pages/content-management/HumanLanguagePage.tsx
Normal file
2262
src/pages/content-management/HumanLanguagePage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,7 +1,5 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { Plus, ArrowRight } from "lucide-react";
|
||||||
import { Plus, ArrowRight, Pencil, Trash2, X } from "lucide-react";
|
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Card, CardContent } from "../../components/ui/card";
|
import { Card, CardContent } from "../../components/ui/card";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -11,250 +9,33 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
DialogFooter,
|
DialogClose,
|
||||||
} from "../../components/ui/dialog";
|
} from "../../components/ui/dialog";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Textarea } from "../../components/ui/textarea";
|
import { Select } from "../../components/ui/select";
|
||||||
import uploadIcon from "../../assets/icons/upload.png";
|
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() {
|
export function LearnEnglishPage() {
|
||||||
const [programs, setPrograms] = useState<LearningProgramListItem[]>([]);
|
const levels = [
|
||||||
const [loading, setLoading] = useState(true);
|
{
|
||||||
const [error, setError] = useState<string | null>(null);
|
id: "beginner",
|
||||||
|
title: "Beginner",
|
||||||
const [editingProgram, setEditingProgram] =
|
description:
|
||||||
useState<LearningProgramListItem | null>(null);
|
"Designed for learners starting from scratch. Focuses on simple grammar, and everyday communication.",
|
||||||
const [editName, setEditName] = useState("");
|
},
|
||||||
const [editDescription, setEditDescription] = useState("");
|
{
|
||||||
const [editThumbnail, setEditThumbnail] = useState("");
|
id: "intermediate",
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
title: "Intermediate",
|
||||||
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
description:
|
||||||
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
"For learners who can communicate at a basic level and want to improve fluency, accuracy, and confidence.",
|
||||||
|
},
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
{
|
||||||
const [createName, setCreateName] = useState("");
|
id: "advanced",
|
||||||
const [createDescription, setCreateDescription] = useState("");
|
title: "Advanced",
|
||||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
description:
|
||||||
const [createSaving, setCreateSaving] = useState(false);
|
"Targets advanced learners aiming for professional, academic, and complex conversational English.",
|
||||||
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
|
@ -265,163 +46,115 @@ export function LearnEnglishPage() {
|
||||||
Learn English
|
Learn English
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-sm text-grayScale-500">
|
<p className="mt-1 text-sm text-grayScale-500">
|
||||||
Manage learning content by program — cards load from the server
|
Manage learning content by level
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={createOpen} onOpenChange={handleCreateDialogOpenChange}>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="h-11 rounded-[6px] bg-brand-500 px-6 font-semibold ">
|
<Button className="h-11 rounded-[6px] bg-brand-500 px-6 font-semibold ">
|
||||||
<Plus className="mr-2 h-5 w-5" />
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
Add Program
|
Add Program
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-2xl flex-col gap-0 overflow-hidden border-none p-0">
|
<DialogContent className="max-w-2xl gap-0 border-none p-0">
|
||||||
<div className="shrink-0">
|
<DialogHeader className="p-8 pb-4">
|
||||||
<DialogHeader className="p-8 pb-4">
|
<DialogTitle className="text-2xl font-bold text-grayScale-700">
|
||||||
<DialogTitle className="text-2xl font-bold text-grayScale-700">
|
Add New Program
|
||||||
Add New Program
|
</DialogTitle>
|
||||||
</DialogTitle>
|
<DialogDescription className="text-sm text-grayScale-400">
|
||||||
<DialogDescription className="text-sm text-grayScale-400">
|
Create a learning program to group courses by learner level
|
||||||
Create a learning program via{" "}
|
</DialogDescription>
|
||||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
</DialogHeader>
|
||||||
POST /programs
|
{/* Gradient Divider */}
|
||||||
</code>
|
<div className="relative">
|
||||||
. Thumbnail can be a URL or a file uploaded through{" "}
|
<div
|
||||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
className="absolute inset-0 flex items-center"
|
||||||
POST /files/upload
|
aria-hidden="true"
|
||||||
</code>
|
>
|
||||||
.
|
<div className="w-full border-t border-grayScale-200" />
|
||||||
</DialogDescription>
|
</div>
|
||||||
</DialogHeader>
|
<div className="relative flex justify-center">
|
||||||
{/* Gradient Divider */}
|
|
||||||
<div className="relative">
|
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 flex items-center"
|
className="h-[0.5px] w-full opacity-20 rounded-full"
|
||||||
aria-hidden="true"
|
style={{
|
||||||
>
|
background: "gray",
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form className="space-y-6 p-8 pt-4">
|
||||||
className="flex min-h-0 flex-1 flex-col"
|
<div className="space-y-2">
|
||||||
onSubmit={(e) => {
|
<label className="text-[15px] text-grayScale-700">
|
||||||
e.preventDefault();
|
Program Name
|
||||||
void handleCreateProgram();
|
</label>
|
||||||
}}
|
<Input
|
||||||
>
|
placeholder="e.g. Beginner"
|
||||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain px-8 py-4">
|
className="h-12 rounded-xl ring-0"
|
||||||
<div className="space-y-2">
|
/>
|
||||||
<label className="text-[15px] text-grayScale-700">
|
</div>
|
||||||
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">
|
<div className="space-y-2">
|
||||||
<label className="text-[15px] text-grayScale-700">
|
<label className="text-[15px] text-grayScale-700">
|
||||||
Description
|
Description
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Input
|
||||||
value={createDescription}
|
placeholder="Short description explaining who this program is for"
|
||||||
onChange={(e) => setCreateDescription(e.target.value)}
|
className="h-12 rounded-xl"
|
||||||
placeholder="Short summary of the program"
|
/>
|
||||||
rows={3}
|
</div>
|
||||||
className="min-h-[88px] resize-y rounded-xl"
|
|
||||||
disabled={createSaving || createUploadingThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-[15px] text-grayScale-700">
|
<label className="text-[15px] text-grayScale-700">
|
||||||
Thumbnail
|
Program Order
|
||||||
</label>
|
</label>
|
||||||
<input
|
<Select className="h-12 rounded-xl">
|
||||||
ref={createThumbnailFileInputRef}
|
<option value="1">1</option>
|
||||||
type="file"
|
<option value="2">2</option>
|
||||||
accept="image/*"
|
<option value="3">3</option>
|
||||||
className="sr-only"
|
</Select>
|
||||||
onChange={(e) => void handleCreateThumbnailFile(e)}
|
</div>
|
||||||
disabled={createSaving || createUploadingThumbnail}
|
|
||||||
/>
|
<div className="space-y-2">
|
||||||
<button
|
<label className="text-[15px] text-grayScale-700">
|
||||||
type="button"
|
Thumbnail
|
||||||
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"
|
</label>
|
||||||
disabled={createSaving || createUploadingThumbnail}
|
<div className="relative group cursor-pointer">
|
||||||
onClick={() => createThumbnailFileInputRef.current?.click()}
|
<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="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
|
<img
|
||||||
src={createThumbnail.trim()}
|
src={uploadIcon}
|
||||||
alt=""
|
alt="Upload icon"
|
||||||
className="h-28 w-full object-cover"
|
className="h-10 w-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<p className="text-sm">
|
||||||
<Input
|
<span className="font-bold text-[#9E2891]">
|
||||||
value={createThumbnail}
|
Click to upload
|
||||||
onChange={(e) => setCreateThumbnail(e.target.value)}
|
</span>{" "}
|
||||||
className="h-12 rounded-xl"
|
<span className="text-grayScale-500">
|
||||||
placeholder="https://…"
|
or drag and drop
|
||||||
disabled={createSaving || createUploadingThumbnail}
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 justify-end gap-3 border-t border-grayScale-100 bg-white px-8 py-4">
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
<Button
|
<DialogClose asChild>
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-12 min-w-[120px] rounded-[6px] border-grayScale-200 font-semibold"
|
className="h-12 min-w-[120px] rounded-[6px] border-grayScale-200 font-semibold"
|
||||||
disabled={createSaving || createUploadingThumbnail}
|
>
|
||||||
onClick={() => handleCreateDialogOpenChange(false)}
|
Cancel
|
||||||
>
|
</Button>
|
||||||
Cancel
|
</DialogClose>
|
||||||
</Button>
|
<Button className="h-12 min-w-[160px] rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600">
|
||||||
<Button
|
Create Program
|
||||||
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -444,263 +177,40 @@ export function LearnEnglishPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{/* Cards Grid */}
|
||||||
<div className="flex flex-col items-center justify-center py-20">
|
<div className="flex flex-warp gap-10">
|
||||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
{levels.map((level) => (
|
||||||
<p className="mt-3 text-sm text-grayScale-500">Loading programs…</p>
|
<Card
|
||||||
</div>
|
key={level.title}
|
||||||
) : error ? (
|
className="group w-[290px] overflow-hidden border-none shadow-soft transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||||
<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()}
|
|
||||||
>
|
>
|
||||||
Try again
|
{/* Gradient Header */}
|
||||||
</Button>
|
<div
|
||||||
</div>
|
className="h-32 w-full"
|
||||||
) : programs.length === 0 ? (
|
style={{
|
||||||
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
|
background:
|
||||||
<p className="text-sm font-medium text-grayScale-600">
|
"linear-gradient(135deg, #9E289180 0%, #9E2891 100%)",
|
||||||
No programs yet
|
}}
|
||||||
</p>
|
/>
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
<CardContent className="bg-white p-6 flex flex-col h-[280px]">
|
||||||
Add programs in the backend or use Add Program when it is connected.
|
<div className="flex-1">
|
||||||
</p>
|
<h3 className="text-xl font-bold text-grayScale-700">
|
||||||
</div>
|
{level.title}
|
||||||
) : (
|
</h3>
|
||||||
<div className="flex flex-wrap gap-10">
|
<p className="mt-2 text-sm leading-relaxed text-grayScale-500">
|
||||||
{programs.map((program) => (
|
{level.description}
|
||||||
<Card
|
</p>
|
||||||
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>
|
|
||||||
<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>
|
||||||
<div
|
<Link to={`/new-content/learn-english/${level.id}/courses`}>
|
||||||
className="h-32 w-full bg-cover bg-center"
|
<Button className="h-11 w-full rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600">
|
||||||
style={
|
View Courses
|
||||||
program.thumbnail?.trim()
|
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
? {
|
|
||||||
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>
|
</Button>
|
||||||
{editThumbnail.trim() ? (
|
</Link>
|
||||||
<div className="flex-1 overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
</CardContent>
|
||||||
<img
|
</Card>
|
||||||
src={editThumbnail.trim()}
|
))}
|
||||||
alt=""
|
</div>
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Video,
|
Video,
|
||||||
|
|
@ -7,39 +7,42 @@ import {
|
||||||
Layers,
|
Layers,
|
||||||
Edit2,
|
Edit2,
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
import { Link, 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 { 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 { cn } from "../../lib/utils";
|
||||||
import { LessonMediaUploadField } from "./components/LessonMediaUploadField";
|
|
||||||
import { VideoCard } from "./components/VideoCard";
|
import { VideoCard } from "./components/VideoCard";
|
||||||
|
|
||||||
const LESSON_THUMB_GRADIENTS = [
|
const MOCK_VIDEOS = [
|
||||||
"from-[#CBD5E1] to-[#94A3B8]",
|
{
|
||||||
"from-[#DBEAFE] to-[#93C5FD]",
|
id: "v1",
|
||||||
"from-[#FEF3C7] to-[#FCD34D]",
|
title: "1.1 Introduction to Formal Greetings",
|
||||||
"from-[#FCE7F3] to-[#F9A8D4]",
|
duration: "08:45",
|
||||||
] as const;
|
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 MOCK_PRACTICES = [
|
const MOCK_PRACTICES = [
|
||||||
{
|
{
|
||||||
|
|
@ -72,15 +75,8 @@ const MOCK_PRACTICES = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
type ModuleDetailState = {
|
|
||||||
moduleName?: string;
|
|
||||||
moduleDescription?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ModuleDetailPage() {
|
export function ModuleDetailPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
|
||||||
const navState = location.state as ModuleDetailState | null;
|
|
||||||
const { level, courseId, moduleId } = useParams<{
|
const { level, courseId, moduleId } = useParams<{
|
||||||
level: string;
|
level: string;
|
||||||
courseId: string;
|
courseId: string;
|
||||||
|
|
@ -88,211 +84,14 @@ export function ModuleDetailPage() {
|
||||||
}>();
|
}>();
|
||||||
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
|
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
|
||||||
const [activeFilter, setActiveFilter] = useState("Draft");
|
const [activeFilter, setActiveFilter] = useState("Draft");
|
||||||
const [lessons, setLessons] = useState<TopLevelModuleLessonItem[]>([]);
|
const [videos] = useState(MOCK_VIDEOS);
|
||||||
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 [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 moduleTitleFallback =
|
const moduleTitle =
|
||||||
moduleId
|
moduleId
|
||||||
?.split("-")
|
?.split("-")
|
||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
.join(" ") || "Module";
|
.join(" ") || "Business English Fundamentals";
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="space-y-10 pt-10 pb-20 animate-in fade-in duration-500">
|
<div className="space-y-10 pt-10 pb-20 animate-in fade-in duration-500">
|
||||||
|
|
@ -311,10 +110,12 @@ export function ModuleDetailPage() {
|
||||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-6">
|
<div className="flex flex-col md:flex-row md:items-start justify-between gap-6">
|
||||||
<div className="">
|
<div className="">
|
||||||
<h1 className="text-2xl font-medium text-grayScale-900 tracking-tight">
|
<h1 className="text-2xl font-medium text-grayScale-900 tracking-tight">
|
||||||
{displayModuleName}
|
Module 3: {moduleTitle}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-grayScale-500 text-[14px] max-w-2xl">
|
<p className="text-grayScale-500 text-[14px] max-w-2xl">
|
||||||
{displayModuleDescription}
|
This module covers essential vocabulary and phrases used in modern
|
||||||
|
business environments, including email etiquette and meeting
|
||||||
|
protocols.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -341,7 +142,7 @@ export function ModuleDetailPage() {
|
||||||
<div className="h-4 w-4 flex items-center justify-center">
|
<div className="h-4 w-4 flex items-center justify-center">
|
||||||
<span className="text-xl leading-none font-light">+</span>
|
<span className="text-xl leading-none font-light">+</span>
|
||||||
</div>
|
</div>
|
||||||
Add Lesson
|
Add Video
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -358,7 +159,7 @@ export function ModuleDetailPage() {
|
||||||
: "text-grayScale-400 hover:text-grayScale-600",
|
: "text-grayScale-400 hover:text-grayScale-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Lesson
|
Video
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("practice")}
|
onClick={() => setActiveTab("practice")}
|
||||||
|
|
@ -377,27 +178,14 @@ export function ModuleDetailPage() {
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
{activeTab === "video" ? (
|
{activeTab === "video" ? (
|
||||||
lessonsLoading ? (
|
videos.length > 0 ? (
|
||||||
<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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
{lessons.map((lesson, i) => (
|
{videos.map((video) => (
|
||||||
<VideoCard
|
<VideoCard
|
||||||
key={lesson.id}
|
key={video.id}
|
||||||
id={lesson.id}
|
{...(video as any)}
|
||||||
title={lesson.title}
|
onEdit={() => console.log("Edit", video.id)}
|
||||||
videoUrl={lesson.video_url}
|
onPublish={() => console.log("Publish", video.id)}
|
||||||
hoverModuleActions
|
|
||||||
thumbnailUrl={resolveThumbnailForPreview(lesson.thumbnail)}
|
|
||||||
thumbnailGradient={LESSON_THUMB_GRADIENTS[i % LESSON_THUMB_GRADIENTS.length]}
|
|
||||||
onEdit={() => openEditLesson(lesson)}
|
|
||||||
onDelete={() => setDeletingLesson(lesson)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -409,11 +197,11 @@ export function ModuleDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
|
<h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
|
||||||
No lessons in this module yet
|
No videos added to this module yet
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed">
|
<p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed">
|
||||||
Lessons are a great way to engage students. Add your first
|
Videos are a great way to engage students. Start building your
|
||||||
lesson to get started.
|
module by adding your first video lesson now.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -425,7 +213,7 @@ export function ModuleDetailPage() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Video className="h-5 w-5" />
|
<Video className="h-5 w-5" />
|
||||||
Add Lesson
|
Add Video
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -463,149 +251,6 @@ export function ModuleDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -58,13 +58,7 @@ const typeColors: Record<QuestionType, string> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PracticeQuestionsPage() {
|
export function PracticeQuestionsPage() {
|
||||||
const { categoryId, courseId, subModuleId, levelId, practiceId } = useParams<{
|
const { categoryId, courseId, subModuleId, practiceId } = useParams()
|
||||||
categoryId: string
|
|
||||||
courseId: string
|
|
||||||
subModuleId?: string
|
|
||||||
levelId?: string
|
|
||||||
practiceId?: string
|
|
||||||
}>()
|
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
const [questions, setQuestions] = useState<PracticeQuestion[]>([])
|
const [questions, setQuestions] = useState<PracticeQuestion[]>([])
|
||||||
|
|
@ -108,14 +102,11 @@ export function PracticeQuestionsPage() {
|
||||||
const [saveError, setSaveError] = useState<string | null>(null)
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
|
|
||||||
const backLink = useMemo(() => {
|
const backLink = useMemo(() => {
|
||||||
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/level/") && levelId) {
|
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/")) {
|
||||||
return "/content/human-language"
|
|
||||||
}
|
|
||||||
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/") && subModuleId) {
|
|
||||||
return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}`
|
return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}`
|
||||||
}
|
}
|
||||||
return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`
|
return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`
|
||||||
}, [location.pathname, categoryId, courseId, subModuleId, levelId])
|
}, [location.pathname, categoryId, courseId, subModuleId])
|
||||||
|
|
||||||
const buildDefaultOptions = (type: QuestionType, sampleAnswerText?: string): DraftOption[] => {
|
const buildDefaultOptions = (type: QuestionType, sampleAnswerText?: string): DraftOption[] => {
|
||||||
if (type === "TRUE_FALSE") {
|
if (type === "TRUE_FALSE") {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,4 +1,3 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { Link, useParams, useNavigate } from "react-router-dom";
|
import { Link, useParams, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
|
@ -7,13 +6,11 @@ import {
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
X,
|
X,
|
||||||
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Card } from "../../components/ui/card";
|
import { Card } from "../../components/ui/card";
|
||||||
import { Textarea } from "../../components/ui/textarea";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -23,51 +20,12 @@ import {
|
||||||
DialogClose,
|
DialogClose,
|
||||||
} from "../../components/ui/dialog";
|
} from "../../components/ui/dialog";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { toast } from "sonner";
|
import { Select } from "../../components/ui/select";
|
||||||
import { ResolvedImage } from "../../components/media/ResolvedImage";
|
|
||||||
import {
|
|
||||||
createExamPrepCatalogCourse,
|
|
||||||
getExamPrepCatalogCourses,
|
|
||||||
updateExamPrepCatalogCourse,
|
|
||||||
deleteExamPrepCatalogCourse,
|
|
||||||
} from "../../api/courses.api";
|
|
||||||
import { uploadImageFile } from "../../api/files.api";
|
|
||||||
import uploadIcon from "../../assets/icons/upload.png";
|
import uploadIcon from "../../assets/icons/upload.png";
|
||||||
|
|
||||||
export function ProgramDetailPage() {
|
export function ProgramDetailPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { programType } = useParams<{ programType: string }>();
|
const { programType } = useParams<{ programType: string }>();
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
const [createName, setCreateName] = useState("");
|
|
||||||
const [createDescription, setCreateDescription] = useState("");
|
|
||||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
|
||||||
const [createThumbnailFromUpload, setCreateThumbnailFromUpload] = useState(false);
|
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
|
|
||||||
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [createdCourses, setCreatedCourses] = useState<
|
|
||||||
{
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
thumbnail?: string | null;
|
|
||||||
sortOrder: number;
|
|
||||||
unitsCount: number;
|
|
||||||
modulesCount: number;
|
|
||||||
lessonsCount: number;
|
|
||||||
}[]
|
|
||||||
>([]);
|
|
||||||
const [catalogLoading, setCatalogLoading] = useState(false);
|
|
||||||
const [editingCourseId, setEditingCourseId] = useState<number | null>(null);
|
|
||||||
const [editName, setEditName] = useState("");
|
|
||||||
const [editDescription, setEditDescription] = useState("");
|
|
||||||
const [editThumbnail, setEditThumbnail] = useState("");
|
|
||||||
const [editSortOrder, setEditSortOrder] = useState("1");
|
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
|
||||||
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
|
||||||
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [deletingCourseId, setDeletingCourseId] = useState<number | null>(null);
|
|
||||||
const [deletingCourse, setDeletingCourse] = useState(false);
|
|
||||||
|
|
||||||
// Mock data for "proficiency" program type
|
// Mock data for "proficiency" program type
|
||||||
const programs: Record<string, any> = {
|
const programs: Record<string, any> = {
|
||||||
|
|
@ -75,7 +33,45 @@ export function ProgramDetailPage() {
|
||||||
title: "English Proficiency Exams",
|
title: "English Proficiency Exams",
|
||||||
description:
|
description:
|
||||||
"Manage exam-based learning programs such as Duolingo and IELTS.",
|
"Manage exam-based learning programs such as Duolingo and IELTS.",
|
||||||
courses: [],
|
courses: [
|
||||||
|
{
|
||||||
|
id: "duolingo",
|
||||||
|
name: "Duolingo English Test",
|
||||||
|
description:
|
||||||
|
"Adaptive exam-style practice for speaking, writing, reading, and listening.",
|
||||||
|
coursesCount: 6,
|
||||||
|
questionTypesCount: 13,
|
||||||
|
logo: (
|
||||||
|
<div className="h-14 w-14 rounded-full bg-[#FFB800] flex items-center justify-center relative overflow-hidden">
|
||||||
|
{/* Simple Duolingo-like representation if image not available */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/20 to-transparent" />
|
||||||
|
<div className="h-8 w-8 bg-white rounded-full flex items-center justify-center">
|
||||||
|
<div className="h-4 w-4 bg-[#FFB800] rounded-sm transform rotate-45" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
buttonText: "Manage Detail",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ielts",
|
||||||
|
name: "IELTS Academic",
|
||||||
|
description:
|
||||||
|
"Full preparation for IELTS speaking, writing, listening, and reading.",
|
||||||
|
coursesCount: 4,
|
||||||
|
questionTypesCount: 18,
|
||||||
|
logo: (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[28px] font-black tracking-tighter text-[#E11D48] ">
|
||||||
|
IELTS
|
||||||
|
</span>
|
||||||
|
<span className="text-[8px] font-bold text-[#E11D48] mt-2 tracking-widest uppercase">
|
||||||
|
™
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
buttonText: "View Detail",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"skill-based": {
|
"skill-based": {
|
||||||
title: "Skill-Based Courses",
|
title: "Skill-Based Courses",
|
||||||
|
|
@ -88,327 +84,6 @@ export function ProgramDetailPage() {
|
||||||
const currentProgram =
|
const currentProgram =
|
||||||
programs[programType || "proficiency"] || programs.proficiency;
|
programs[programType || "proficiency"] || programs.proficiency;
|
||||||
|
|
||||||
const loadCatalogCourses = useCallback(async () => {
|
|
||||||
if (programType !== "proficiency") return;
|
|
||||||
setCatalogLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await getExamPrepCatalogCourses({ limit: 20, offset: 0 });
|
|
||||||
const rows = response.data?.data?.catalog_courses;
|
|
||||||
const list = Array.isArray(rows) ? rows : [];
|
|
||||||
setCreatedCourses(
|
|
||||||
list.map((row) => ({
|
|
||||||
id: Number(row.id),
|
|
||||||
name: row.name?.trim() || `Course ${row.id}`,
|
|
||||||
description: row.description?.trim() || "—",
|
|
||||||
thumbnail: row.thumbnail?.trim() || null,
|
|
||||||
sortOrder: Number(row.sort_order ?? 0),
|
|
||||||
unitsCount: Number(row.units_count ?? 0),
|
|
||||||
modulesCount: Number(row.modules_count ?? 0),
|
|
||||||
lessonsCount: Number(row.lessons_count ?? 0),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
toast.error("Failed to fetch catalog courses");
|
|
||||||
setCreatedCourses([]);
|
|
||||||
} finally {
|
|
||||||
setCatalogLoading(false);
|
|
||||||
}
|
|
||||||
}, [programType]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadCatalogCourses();
|
|
||||||
}, [loadCatalogCourses]);
|
|
||||||
const proficiencyCourses = [
|
|
||||||
...currentProgram.courses,
|
|
||||||
...createdCourses.map((course) => ({
|
|
||||||
id: course.id,
|
|
||||||
name: course.name,
|
|
||||||
description: course.description,
|
|
||||||
units_count: course.unitsCount,
|
|
||||||
modules_count: course.modulesCount,
|
|
||||||
lessons_count: course.lessonsCount,
|
|
||||||
logo: null,
|
|
||||||
thumbnail: course.thumbnail ?? "",
|
|
||||||
sort_order: course.sortOrder,
|
|
||||||
buttonText: "View Detail",
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
const isHttpUrl = (value: string) =>
|
|
||||||
value.startsWith("http://") || value.startsWith("https://");
|
|
||||||
|
|
||||||
const isMinioUrl = (value: string) => {
|
|
||||||
try {
|
|
||||||
const url = new URL(value);
|
|
||||||
return url.host === "s3.yimaruacademy.com";
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const autoUploadThumbnailUrlIfNeeded = async (rawValue: string) => {
|
|
||||||
const candidate = rawValue.trim();
|
|
||||||
if (!candidate) return;
|
|
||||||
if (!isHttpUrl(candidate)) return;
|
|
||||||
if (isMinioUrl(candidate)) return;
|
|
||||||
if (uploadingThumbnail || creating) return;
|
|
||||||
|
|
||||||
setUploadingThumbnail(true);
|
|
||||||
try {
|
|
||||||
const uploaded = await uploadImageFile(candidate);
|
|
||||||
const uploadedUrl = uploaded.data?.data?.url?.trim();
|
|
||||||
if (!uploadedUrl) {
|
|
||||||
throw new Error("Failed to upload thumbnail URL to MinIO");
|
|
||||||
}
|
|
||||||
setCreateThumbnail(uploadedUrl);
|
|
||||||
setCreateThumbnailFromUpload(true);
|
|
||||||
toast.success("Thumbnail URL uploaded to MinIO");
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload thumbnail URL";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveThumbnailToMinioUrl = async (rawValue: string) => {
|
|
||||||
const trimmed = rawValue.trim();
|
|
||||||
if (!trimmed) return "";
|
|
||||||
if (!isHttpUrl(trimmed) || isMinioUrl(trimmed)) return trimmed;
|
|
||||||
const uploaded = await uploadImageFile(trimmed);
|
|
||||||
const uploadedUrl = uploaded.data?.data?.url?.trim();
|
|
||||||
if (!uploadedUrl) {
|
|
||||||
throw new Error("Failed to upload thumbnail URL to MinIO");
|
|
||||||
}
|
|
||||||
return uploadedUrl;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateCourse = async () => {
|
|
||||||
if (programType !== "proficiency") {
|
|
||||||
toast.error("Create Course is supported only for proficiency catalog.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const name = createName.trim();
|
|
||||||
if (!name) {
|
|
||||||
toast.error("Course name is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCreating(true);
|
|
||||||
try {
|
|
||||||
let thumbnailToSend: string | null = createThumbnail.trim() || null;
|
|
||||||
if (
|
|
||||||
thumbnailToSend &&
|
|
||||||
!createThumbnailFromUpload &&
|
|
||||||
isHttpUrl(thumbnailToSend) &&
|
|
||||||
!isMinioUrl(thumbnailToSend)
|
|
||||||
) {
|
|
||||||
const uploaded = await uploadImageFile(thumbnailToSend);
|
|
||||||
const uploadedUrl = uploaded.data?.data?.url?.trim();
|
|
||||||
if (!uploadedUrl) {
|
|
||||||
throw new Error("Failed to upload thumbnail URL to MinIO");
|
|
||||||
}
|
|
||||||
thumbnailToSend = uploadedUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await createExamPrepCatalogCourse({
|
|
||||||
name,
|
|
||||||
description: createDescription.trim() || null,
|
|
||||||
thumbnail: thumbnailToSend,
|
|
||||||
});
|
|
||||||
const row = response.data?.data;
|
|
||||||
if (!row?.id) {
|
|
||||||
throw new Error("Missing created course payload");
|
|
||||||
}
|
|
||||||
setCreatedCourses((prev) => [
|
|
||||||
{
|
|
||||||
id: row.id,
|
|
||||||
name: row.name ?? name,
|
|
||||||
description: row.description?.trim() || createDescription.trim() || "—",
|
|
||||||
thumbnail: row.thumbnail?.trim() || null,
|
|
||||||
sortOrder: Number(row.sort_order ?? 0),
|
|
||||||
unitsCount: Number(row.units_count ?? 0),
|
|
||||||
modulesCount: Number(row.modules_count ?? 0),
|
|
||||||
lessonsCount: Number(row.lessons_count ?? 0),
|
|
||||||
},
|
|
||||||
...prev,
|
|
||||||
]);
|
|
||||||
await loadCatalogCourses();
|
|
||||||
toast.success("Course created");
|
|
||||||
setCreateName("");
|
|
||||||
setCreateDescription("");
|
|
||||||
setCreateThumbnail("");
|
|
||||||
setCreateThumbnailFromUpload(false);
|
|
||||||
setCreateOpen(false);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to create course";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setCreating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditCourse = (course: (typeof proficiencyCourses)[number]) => {
|
|
||||||
const idNum = Number(course.id);
|
|
||||||
if (!Number.isFinite(idNum)) return;
|
|
||||||
setEditingCourseId(idNum);
|
|
||||||
setEditName(String(course.name ?? ""));
|
|
||||||
setEditDescription(String(course.description ?? ""));
|
|
||||||
setEditThumbnail(String(course.thumbnail ?? ""));
|
|
||||||
setEditSortOrder(String(course.sort_order ?? 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeEditCourse = () => {
|
|
||||||
if (savingEdit || uploadingEditThumbnail) return;
|
|
||||||
setEditingCourseId(null);
|
|
||||||
setEditName("");
|
|
||||||
setEditDescription("");
|
|
||||||
setEditThumbnail("");
|
|
||||||
setEditSortOrder("1");
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
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 (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload thumbnail";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingEditThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveEditCourse = async () => {
|
|
||||||
if (!editingCourseId) return;
|
|
||||||
const name = editName.trim();
|
|
||||||
if (!name) {
|
|
||||||
toast.error("Course name is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sortOrderNum = Number(editSortOrder);
|
|
||||||
if (!Number.isFinite(sortOrderNum) || sortOrderNum < 0) {
|
|
||||||
toast.error("Sort order must be a valid number");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSavingEdit(true);
|
|
||||||
try {
|
|
||||||
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
|
|
||||||
const response = await updateExamPrepCatalogCourse(editingCourseId, {
|
|
||||||
name,
|
|
||||||
description: editDescription.trim() || null,
|
|
||||||
thumbnail: minioThumbnail || null,
|
|
||||||
sort_order: sortOrderNum,
|
|
||||||
});
|
|
||||||
const row = response.data?.data;
|
|
||||||
setCreatedCourses((prev) =>
|
|
||||||
prev.map((course) =>
|
|
||||||
course.id === editingCourseId
|
|
||||||
? {
|
|
||||||
...course,
|
|
||||||
name: row?.name ?? name,
|
|
||||||
description: row?.description?.trim() || editDescription.trim() || "—",
|
|
||||||
thumbnail: row?.thumbnail?.trim() || null,
|
|
||||||
sortOrder: Number(row?.sort_order ?? sortOrderNum),
|
|
||||||
unitsCount: Number(row?.units_count ?? course.unitsCount ?? 0),
|
|
||||||
modulesCount: Number(row?.modules_count ?? course.modulesCount ?? 0),
|
|
||||||
lessonsCount: Number(row?.lessons_count ?? course.lessonsCount ?? 0),
|
|
||||||
}
|
|
||||||
: course,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await loadCatalogCourses();
|
|
||||||
toast.success("Course updated");
|
|
||||||
closeEditCourse();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to update course";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setSavingEdit(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteCourse = async () => {
|
|
||||||
if (!deletingCourseId) return;
|
|
||||||
setDeletingCourse(true);
|
|
||||||
try {
|
|
||||||
await deleteExamPrepCatalogCourse(deletingCourseId);
|
|
||||||
await loadCatalogCourses();
|
|
||||||
toast.success("Course deleted");
|
|
||||||
setDeletingCourseId(null);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to delete course";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setDeletingCourse(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateCourseThumbnailFile = 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;
|
|
||||||
}
|
|
||||||
setUploadingThumbnail(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);
|
|
||||||
setCreateThumbnailFromUpload(true);
|
|
||||||
toast.success("Thumbnail uploaded");
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload thumbnail";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
|
|
@ -432,136 +107,84 @@ export function ProgramDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
<div className="flex items-center gap-3 pt-2">
|
||||||
<Dialog
|
<Dialog>
|
||||||
open={createOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open && (creating || uploadingThumbnail)) return;
|
|
||||||
setCreateOpen(open);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white transition-all flex items-center gap-2">
|
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white transition-all flex items-center gap-2">
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="h-5 w-5" />
|
||||||
Create Course
|
Create Course
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
|
<DialogContent className="max-w-[600px] p-0 border-none rounded-[16px] overflow-hidden">
|
||||||
<div className="flex min-h-0 flex-1 flex-col bg-white">
|
<div className="bg-white">
|
||||||
<DialogHeader className="shrink-0 border-b border-grayScale-200 px-8 py-6 flex flex-row items-center justify-between">
|
<DialogHeader className="px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
|
||||||
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
|
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
|
||||||
Create Course
|
Create Course
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 space-y-8 overflow-y-auto p-8">
|
<div className="p-8 space-y-8">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-[15px] text-grayScale-800">
|
<label className="text-[15px] text-grayScale-800">
|
||||||
Name
|
Name
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={createName}
|
|
||||||
onChange={(e) => setCreateName(e.target.value)}
|
|
||||||
placeholder="e.g. TOEFL, IELTS"
|
placeholder="e.g. TOEFL, IELTS"
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
||||||
disabled={creating}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-[15px] text-grayScale-800">
|
<label className="text-[15px] text-grayScale-800">
|
||||||
Description
|
Course Order
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Select defaultValue="1">
|
||||||
value={createDescription}
|
<option value="1">1</option>
|
||||||
onChange={(e) => setCreateDescription(e.target.value)}
|
<option value="2">2</option>
|
||||||
placeholder="Optional description"
|
<option value="3">3</option>
|
||||||
rows={4}
|
</Select>
|
||||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
|
||||||
disabled={creating}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnail Field */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-[15px] text-grayScale-800">
|
<label className="text-[15px] text-grayScale-800">
|
||||||
Thumbnail
|
Thumbnail
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className="relative group cursor-pointer">
|
||||||
ref={createThumbnailFileInputRef}
|
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all ">
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="sr-only"
|
|
||||||
onChange={(e) => void handleCreateCourseThumbnailFile(e)}
|
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative w-full cursor-pointer rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white px-10 py-8 text-left transition-all hover:border-brand-300 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
onClick={() => createThumbnailFileInputRef.current?.click()}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center">
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<img src={uploadIcon} alt="" className="h-10 w-10" />
|
<img
|
||||||
|
src={uploadIcon}
|
||||||
|
alt="Upload icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[15px]">
|
<p className="text-[15px]">
|
||||||
<span className="font-bold text-brand-500">
|
<span className="text-brand-500 font-bold hover:underline">
|
||||||
{uploadingThumbnail ? "Uploading…" : "Click to upload"}
|
Click to upload
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
<span className="text-grayScale-500">or paste a URL below</span>
|
<span className="text-grayScale-500">
|
||||||
|
or drag and drop
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1.5 text-[12px] uppercase tracking-widest text-grayScale-400">
|
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
|
||||||
JPG, PNG (MAX 5 MB)
|
JPG, PNG (MAX 1 MB)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
{createThumbnail.trim() ? (
|
|
||||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
|
||||||
<ResolvedImage
|
|
||||||
src={createThumbnail.trim()}
|
|
||||||
alt=""
|
|
||||||
className="h-28 w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<Input
|
|
||||||
value={createThumbnail}
|
|
||||||
onChange={(e) => {
|
|
||||||
setCreateThumbnail(e.target.value);
|
|
||||||
setCreateThumbnailFromUpload(false);
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
void autoUploadThumbnailUrlIfNeeded(e.target.value);
|
|
||||||
}}
|
|
||||||
onPaste={(e) => {
|
|
||||||
const pasted = e.clipboardData.getData("text");
|
|
||||||
if (!pasted) return;
|
|
||||||
setCreateThumbnail(pasted);
|
|
||||||
setCreateThumbnailFromUpload(false);
|
|
||||||
void autoUploadThumbnailUrlIfNeeded(pasted);
|
|
||||||
}}
|
|
||||||
placeholder="Optional thumbnail URL (or leave empty for null)"
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
<div className="px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
|
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<Button
|
<Button className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600">
|
||||||
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
|
Create Program
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
onClick={() => void handleCreateCourse()}
|
|
||||||
>
|
|
||||||
{creating ? "Creating..." : "Create Course"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -598,70 +221,13 @@ export function ProgramDetailPage() {
|
||||||
|
|
||||||
{/* Cards Grid */}
|
{/* Cards Grid */}
|
||||||
<div className="flex flex-wrap gap-8 mt-10">
|
<div className="flex flex-wrap gap-8 mt-10">
|
||||||
{programType === "proficiency" && catalogLoading ? (
|
{currentProgram.courses.map((course: any) => (
|
||||||
<p className="text-sm text-grayScale-500">Loading catalog courses...</p>
|
<Card
|
||||||
) : null}
|
key={course.id}
|
||||||
{(programType === "proficiency"
|
className="bg-white w-[500px] rounded-[20px] border border-grayScale-100 p-6 flex flex-col items-start shadow-sm hover:shadow-md transition-shadow"
|
||||||
? proficiencyCourses
|
>
|
||||||
: currentProgram.courses
|
|
||||||
).length === 0 && !catalogLoading ? (
|
|
||||||
<div className="w-full 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 catalog courses yet
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
|
||||||
Create your first exam-prep catalog course to start organizing units, modules, and lessons.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
(programType === "proficiency"
|
|
||||||
? proficiencyCourses
|
|
||||||
: currentProgram.courses
|
|
||||||
).map((course: any) => (
|
|
||||||
<Card
|
|
||||||
key={course.id}
|
|
||||||
className="group relative bg-white w-[500px] rounded-[20px] border border-grayScale-100 p-6 flex flex-col items-start shadow-sm hover:shadow-md transition-shadow"
|
|
||||||
>
|
|
||||||
{programType === "proficiency" ? (
|
|
||||||
<div className="absolute right-3 top-3 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"
|
|
||||||
onClick={() => openEditCourse(course)}
|
|
||||||
aria-label={`Edit ${course.name}`}
|
|
||||||
>
|
|
||||||
<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"
|
|
||||||
onClick={() => setDeletingCourseId(Number(course.id))}
|
|
||||||
aria-label={`Delete ${course.name}`}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="h-16 flex items-center">
|
<div className="h-16 flex items-center">{course.logo}</div>
|
||||||
{course.thumbnail ? (
|
|
||||||
<ResolvedImage
|
|
||||||
src={course.thumbnail}
|
|
||||||
alt={course.name}
|
|
||||||
className="h-14 w-14 rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
) : course.logo ? (
|
|
||||||
course.logo
|
|
||||||
) : (
|
|
||||||
<div className="h-14 w-14 rounded-full bg-brand-50 text-brand-600 grid place-items-center text-xs font-bold">
|
|
||||||
{String(course.name ?? "C").slice(0, 2).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="space-y-4 pt-2 flex-1">
|
<div className="space-y-4 pt-2 flex-1">
|
||||||
|
|
@ -678,19 +244,13 @@ export function ProgramDetailPage() {
|
||||||
<div className="h-10 px-4 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-700">
|
<div className="h-10 px-4 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-700">
|
||||||
<ClipboardList className="h-3 w-3 text-grayScale-400" />
|
<ClipboardList className="h-3 w-3 text-grayScale-400" />
|
||||||
<span className="text-[12px] ">
|
<span className="text-[12px] ">
|
||||||
{Number(course.units_count ?? 0)} Units
|
{course.coursesCount} Courses
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-10 px-4 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-700">
|
<div className="h-10 px-4 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-700">
|
||||||
<ListChecks className="h-3 w-3 text-grayScale-400" />
|
<ListChecks className="h-3 w-3 text-grayScale-400" />
|
||||||
<span className="text-[12px] ">
|
<span className="text-[12px] ">
|
||||||
{Number(course.modules_count ?? 0)} Modules
|
{course.questionTypesCount} Question Types
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-10 px-4 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-700">
|
|
||||||
<ListChecks className="h-3 w-3 text-grayScale-400" />
|
|
||||||
<span className="text-[12px] ">
|
|
||||||
{Number(course.lessons_count ?? 0)} Lessons
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -705,166 +265,9 @@ export function ProgramDetailPage() {
|
||||||
{course.buttonText}
|
{course.buttonText}
|
||||||
<ChevronRight className="h-5 w-5 transition-transform group-hover/btn:translate-x-1" />
|
<ChevronRight className="h-5 w-5 transition-transform group-hover/btn:translate-x-1" />
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={editingCourseId !== null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open && (savingEdit || uploadingEditThumbnail)) return;
|
|
||||||
if (!open) closeEditCourse();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col bg-white">
|
|
||||||
<DialogHeader className="shrink-0 border-b border-grayScale-200 px-8 py-6 flex flex-row items-center justify-between">
|
|
||||||
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
|
|
||||||
Edit Course
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="min-h-0 flex-1 space-y-8 overflow-y-auto p-8">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Name</label>
|
|
||||||
<Input
|
|
||||||
value={editName}
|
|
||||||
onChange={(e) => setEditName(e.target.value)}
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Description</label>
|
|
||||||
<Textarea
|
|
||||||
value={editDescription}
|
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
|
||||||
rows={4}
|
|
||||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Sort Order</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={editSortOrder}
|
|
||||||
onChange={(e) => setEditSortOrder(e.target.value)}
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
|
|
||||||
<input
|
|
||||||
ref={editThumbnailFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="sr-only"
|
|
||||||
onChange={(e) => void handleEditThumbnailFile(e)}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative w-full cursor-pointer rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white px-10 py-8 text-left transition-all hover:border-brand-300 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
onClick={() => editThumbnailFileInputRef.current?.click()}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
>
|
|
||||||
<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-[15px]">
|
|
||||||
<span className="font-bold text-brand-500">
|
|
||||||
{uploadingEditThumbnail ? "Uploading…" : "Click to upload"}
|
|
||||||
</span>{" "}
|
|
||||||
<span className="text-grayScale-500">or paste a URL below</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 text-[12px] uppercase tracking-widest text-grayScale-400">
|
|
||||||
JPG, PNG (MAX 5 MB)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{editThumbnail.trim() ? (
|
|
||||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
|
||||||
<ResolvedImage
|
|
||||||
src={editThumbnail.trim()}
|
|
||||||
alt=""
|
|
||||||
className="h-28 w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<Input
|
|
||||||
value={editThumbnail}
|
|
||||||
onChange={(e) => setEditThumbnail(e.target.value)}
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
|
||||||
placeholder="https://..."
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
|
|
||||||
onClick={closeEditCourse}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
|
|
||||||
onClick={() => void handleSaveEditCourse()}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
>
|
|
||||||
{savingEdit ? "Saving..." : "Save Changes"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={deletingCourseId !== null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open && !deletingCourse) setDeletingCourseId(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-w-md rounded-[16px] border-none p-0 overflow-hidden">
|
|
||||||
<div className="bg-white">
|
|
||||||
<DialogHeader className="border-b border-grayScale-100 px-6 py-5">
|
|
||||||
<DialogTitle className="text-lg font-bold text-grayScale-900">
|
|
||||||
Delete Course
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="px-6 py-6 text-sm text-grayScale-600">
|
|
||||||
Are you sure you want to delete this course? This action cannot be undone.
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 border-t border-grayScale-100 px-6 py-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDeletingCourseId(null)}
|
|
||||||
disabled={deletingCourse}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="bg-red-500 hover:bg-red-600"
|
|
||||||
onClick={() => void handleDeleteCourse()}
|
|
||||||
disabled={deletingCourse}
|
|
||||||
>
|
|
||||||
{deletingCourse ? "Deleting..." : "Delete"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { ArrowLeft, ChevronDown, ChevronRight, Image as ImageIcon, Mic, Plus, Trash2, Upload } from "lucide-react"
|
import { ArrowLeft, ChevronDown, ChevronRight, Image as ImageIcon, Loader2, Mic, Plus, Trash2, Upload } from "lucide-react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
|
|
@ -1926,7 +1926,7 @@ export function SpeakingPage() {
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
>
|
>
|
||||||
{uploadingIntroVideo ? (
|
{uploadingIntroVideo ? (
|
||||||
<SpinnerIcon className="h-4 w-4" alt="" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Upload className="h-4 w-4" />
|
<Upload className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
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,10 +103,9 @@ export function SubModuleContentPage() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const subCoursesRes = await getSubModulesByCourse(Number(courseId))
|
const subCoursesRes = await getSubModulesByCourse(Number(courseId))
|
||||||
const list = subCoursesRes.data?.data?.sub_courses
|
const foundSubCourse = subCoursesRes.data.data.sub_courses?.find(
|
||||||
const foundSubCourse = Array.isArray(list)
|
(sc) => sc.id === Number(subModuleId)
|
||||||
? list.find((sc) => sc.id === Number(subModuleId))
|
)
|
||||||
: undefined
|
|
||||||
setSubCourse(foundSubCourse ?? null)
|
setSubCourse(foundSubCourse ?? null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch course data:", err)
|
console.error("Failed to fetch course data:", err)
|
||||||
|
|
@ -124,9 +123,7 @@ export function SubModuleContentPage() {
|
||||||
setPracticesLoading(true)
|
setPracticesLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await getQuestionSetsByOwner("SUB_MODULE", Number(subModuleId))
|
const res = await getQuestionSetsByOwner("SUB_MODULE", Number(subModuleId))
|
||||||
const raw = res.data?.data
|
setPractices(res.data.data ?? [])
|
||||||
const list = Array.isArray(raw) ? raw : (raw as { question_sets?: QuestionSet[] })?.question_sets ?? []
|
|
||||||
setPractices(Array.isArray(list) ? list : [])
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch practices:", err)
|
console.error("Failed to fetch practices:", err)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -139,8 +136,7 @@ export function SubModuleContentPage() {
|
||||||
setVideosLoading(true)
|
setVideosLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await getVideosBySubModule(Number(subModuleId))
|
const res = await getVideosBySubModule(Number(subModuleId))
|
||||||
const vids = res.data?.data?.videos ?? []
|
setVideos(res.data.data.videos ?? [])
|
||||||
setVideos(Array.isArray(vids) ? vids : [])
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch videos:", err)
|
console.error("Failed to fetch videos:", err)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -158,7 +154,7 @@ export function SubModuleContentPage() {
|
||||||
limit: ratingsPageSize,
|
limit: ratingsPageSize,
|
||||||
offset,
|
offset,
|
||||||
})
|
})
|
||||||
setRatings(res.data?.data ?? [])
|
setRatings(res.data.data ?? [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch ratings:", err)
|
console.error("Failed to fetch ratings:", err)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -409,8 +405,8 @@ export function SubModuleContentPage() {
|
||||||
const idMatch = video.video_url?.match(/(\d{5,})/)
|
const idMatch = video.video_url?.match(/(\d{5,})/)
|
||||||
const vimeoId = idMatch?.[1] ?? "76979871" // fallback to Big Buck Bunny
|
const vimeoId = idMatch?.[1] ?? "76979871" // fallback to Big Buck Bunny
|
||||||
const res = await getVimeoSample(vimeoId)
|
const res = await getVimeoSample(vimeoId)
|
||||||
setPreviewIframe(res.data?.data?.iframe ?? "")
|
setPreviewIframe(res.data.data.iframe)
|
||||||
setPreviewVideo(res.data?.data?.video ?? null)
|
setPreviewVideo(res.data.data.video)
|
||||||
} catch {
|
} catch {
|
||||||
setPreviewIframe("")
|
setPreviewIframe("")
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -418,7 +414,7 @@ export function SubModuleContentPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredPractices = (Array.isArray(practices) ? practices : []).filter((practice) => {
|
const filteredPractices = practices.filter((practice) => {
|
||||||
if (statusFilter === "all") return true
|
if (statusFilter === "all") return true
|
||||||
if (statusFilter === "published") return practice.status === "PUBLISHED"
|
if (statusFilter === "published") return practice.status === "PUBLISHED"
|
||||||
if (statusFilter === "draft") return practice.status === "DRAFT"
|
if (statusFilter === "draft") return practice.status === "DRAFT"
|
||||||
|
|
@ -444,19 +440,6 @@ export function SubModuleContentPage() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!subCourse) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-20">
|
|
||||||
<img src={alertSrc} alt="" className="h-12 w-12" />
|
|
||||||
<p className="mt-3 text-sm font-medium text-grayScale-600">Sub-module not found</p>
|
|
||||||
<p className="mt-1 text-sm text-grayScale-400">It may have been removed or the link is invalid.</p>
|
|
||||||
<Button className="mt-6" variant="outline" asChild>
|
|
||||||
<Link to={`/content/category/${categoryId}/courses/${courseId}/sub-modules`}>Back to sub-modules</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Back Button */}
|
{/* Back Button */}
|
||||||
|
|
@ -607,7 +590,7 @@ export function SubModuleContentPage() {
|
||||||
<div className="flex items-center gap-3 text-xs text-grayScale-400">
|
<div className="flex items-center gap-3 text-xs text-grayScale-400">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Layers className="h-3.5 w-3.5" />
|
<Layers className="h-3.5 w-3.5" />
|
||||||
<span>{String(practice.owner_type ?? "SUB_MODULE").replace(/_/g, " ")}</span>
|
<span>{practice.owner_type.replace("_", " ")}</span>
|
||||||
</div>
|
</div>
|
||||||
{practice.shuffle_questions && (
|
{practice.shuffle_questions && (
|
||||||
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-600 ring-1 ring-inset ring-amber-200">Shuffle ON</span>
|
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-600 ring-1 ring-inset ring-amber-200">Shuffle ON</span>
|
||||||
|
|
@ -616,13 +599,11 @@ export function SubModuleContentPage() {
|
||||||
|
|
||||||
<div className="mt-auto flex items-center justify-between border-t border-grayScale-100 pt-3">
|
<div className="mt-auto flex items-center justify-between border-t border-grayScale-100 pt-3">
|
||||||
<span className="text-xs font-medium text-grayScale-400">
|
<span className="text-xs font-medium text-grayScale-400">
|
||||||
{practice.created_at
|
{new Date(practice.created_at).toLocaleDateString("en-US", {
|
||||||
? new Date(practice.created_at).toLocaleDateString("en-US", {
|
month: "short",
|
||||||
month: "short",
|
day: "numeric",
|
||||||
day: "numeric",
|
year: "numeric",
|
||||||
year: "numeric",
|
})}
|
||||||
})
|
|
||||||
: "—"}
|
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
|
<div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useState, type FormEvent } from "react";
|
import { X } from "lucide-react";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -9,137 +9,51 @@ import {
|
||||||
DialogClose,
|
DialogClose,
|
||||||
} from "../../../components/ui/dialog";
|
} from "../../../components/ui/dialog";
|
||||||
import { Input } from "../../../components/ui/input";
|
import { Input } from "../../../components/ui/input";
|
||||||
import { Textarea } from "../../../components/ui/textarea";
|
import { Select } from "../../../components/ui/select";
|
||||||
import { toast } from "sonner";
|
import uploadIcon from "../../../assets/icons/upload.png";
|
||||||
import { createTopLevelCourseModule } from "../../../api/courses.api";
|
|
||||||
import { ModuleIconUploadField } from "./ModuleIconUploadField";
|
|
||||||
|
|
||||||
interface AddModuleModalProps {
|
interface AddModuleModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
courseId: number;
|
|
||||||
onCreated?: () => void | Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddModuleModal({
|
export function AddModuleModal({ isOpen, onClose }: AddModuleModalProps) {
|
||||||
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);
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<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">
|
<DialogContent className="max-w-2xl gap-0 border-none p-0 overflow-hidden rounded-[16px] shadow-2xl">
|
||||||
<div className="flex-shrink-0">
|
<DialogHeader className="p-8 pb-4 relative">
|
||||||
<DialogHeader className="relative p-8 pb-4">
|
<DialogTitle className="text-2xl font-bold text-grayScale-700">
|
||||||
<DialogTitle className="text-2xl font-bold text-grayScale-700">
|
Add New Module
|
||||||
Add New Module
|
</DialogTitle>
|
||||||
</DialogTitle>
|
<DialogDescription className="text-sm text-grayScale-400">
|
||||||
<DialogDescription className="text-sm text-grayScale-400">
|
Create a module to organize videos and practices.
|
||||||
Create a module with{" "}
|
</DialogDescription>
|
||||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
</DialogHeader>
|
||||||
POST /courses/:courseId/modules
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="relative">
|
{/* 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">
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 flex items-center"
|
className="h-[0.5px] w-full opacity-20"
|
||||||
aria-hidden="true"
|
style={{ background: "gray" }}
|
||||||
>
|
/>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form className="space-y-6 p-8 pt-4">
|
||||||
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">
|
<div className="space-y-2">
|
||||||
<label className="text-[15px] font-medium text-grayScale-700">
|
<label className="text-[15px] font-medium text-grayScale-700">
|
||||||
Module title
|
Module Title
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={name}
|
placeholder="e.g. Daily Introductions"
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="e.g. Greetings & Introductions"
|
|
||||||
className="h-12 rounded-xl"
|
className="h-12 rounded-xl"
|
||||||
disabled={submitting}
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -147,40 +61,63 @@ export function AddModuleModal({
|
||||||
<label className="text-[15px] font-medium text-grayScale-700">
|
<label className="text-[15px] font-medium text-grayScale-700">
|
||||||
Description
|
Description
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Input
|
||||||
value={description}
|
placeholder="Short description of this module"
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
className="h-12 rounded-xl"
|
||||||
placeholder="Learn to introduce yourself and talk about your life."
|
|
||||||
className="min-h-[88px] resize-y rounded-xl"
|
|
||||||
disabled={submitting}
|
|
||||||
rows={3}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ModuleIconUploadField
|
<div className="space-y-2">
|
||||||
value={icon}
|
<label className="text-[15px] font-medium text-grayScale-700">
|
||||||
onChange={setIcon}
|
Module Order
|
||||||
disabled={submitting}
|
</label>
|
||||||
onUploadBusyChange={setIconUploadBusy}
|
<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>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-4">
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-12 min-w-[120px] rounded-xl border-grayScale-200 font-semibold"
|
className="h-12 min-w-[120px] rounded-xl border-grayScale-200 font-semibold"
|
||||||
disabled={submitting || iconUploadBusy}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
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"
|
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"
|
||||||
disabled={submitting || iconUploadBusy}
|
|
||||||
>
|
>
|
||||||
{submitting ? "Creating…" : "Create module"}
|
Create Module
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,539 +0,0 @@
|
||||||
import React, { useState } from "react";
|
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
closestCenter,
|
|
||||||
KeyboardSensor,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
DragOverlay,
|
|
||||||
} from "@dnd-kit/core";
|
|
||||||
import type {
|
|
||||||
DragEndEvent,
|
|
||||||
DragStartEvent,
|
|
||||||
UniqueIdentifier,
|
|
||||||
} from "@dnd-kit/core";
|
|
||||||
import {
|
|
||||||
arrayMove,
|
|
||||||
SortableContext,
|
|
||||||
sortableKeyboardCoordinates,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
useSortable,
|
|
||||||
} from "@dnd-kit/sortable";
|
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
|
||||||
import {
|
|
||||||
GripVertical,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
LayoutGrid,
|
|
||||||
BookOpen,
|
|
||||||
Layers,
|
|
||||||
PlayCircle,
|
|
||||||
RotateCcw,
|
|
||||||
Edit2,
|
|
||||||
Trash2,
|
|
||||||
Image as ImageIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { cn } from "../../../lib/utils";
|
|
||||||
|
|
||||||
// --- Types ---
|
|
||||||
export type ItemType = "program" | "course" | "module" | "lesson";
|
|
||||||
|
|
||||||
export interface BaseItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
thumbnail?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Program extends BaseItem {}
|
|
||||||
export interface Course extends BaseItem {
|
|
||||||
programId: string;
|
|
||||||
}
|
|
||||||
export interface Module extends BaseItem {
|
|
||||||
courseId: string;
|
|
||||||
}
|
|
||||||
export interface Lesson extends BaseItem {
|
|
||||||
moduleId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Mock Data ---
|
|
||||||
const initialPrograms: Program[] = [
|
|
||||||
{
|
|
||||||
id: "p1",
|
|
||||||
name: "Web Development Masterclass",
|
|
||||||
thumbnail:
|
|
||||||
"https://images.unsplash.com/photo-1498050108023-c5249f4df085?w=100&h=100&fit=crop",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "p2",
|
|
||||||
name: "Mobile App Development",
|
|
||||||
thumbnail:
|
|
||||||
"https://images.unsplash.com/photo-1512941937669-90a1b58e7e9c?w=100&h=100&fit=crop",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "p3",
|
|
||||||
name: "UI/UX Design Fundamentals",
|
|
||||||
thumbnail:
|
|
||||||
"https://images.unsplash.com/photo-1586717791821-3f44a563eb4c?w=100&h=100&fit=crop",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const initialCourses: Course[] = [
|
|
||||||
{ id: "c1", name: "React for Beginners", programId: "p1" },
|
|
||||||
{ id: "c2", name: "Advanced Node.js", programId: "p1" },
|
|
||||||
{ id: "c3", name: "Swift UI Intro", programId: "p2" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const initialModules: Module[] = [
|
|
||||||
{ id: "m1", name: "Introduction to Hooks", courseId: "c1" },
|
|
||||||
{ id: "m2", name: "State Management", courseId: "c1" },
|
|
||||||
{ id: "m3", name: "Backend Architecture", courseId: "c2" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const initialLessons: Lesson[] = [
|
|
||||||
{ id: "l1", name: "What is useState?", moduleId: "m1" },
|
|
||||||
{ id: "l2", name: "useEffect deep dive", moduleId: "m1" },
|
|
||||||
{ id: "l3", name: "Redux Setup", moduleId: "m2" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// --- Components ---
|
|
||||||
|
|
||||||
interface SortableItemProps {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
thumbnail?: string;
|
|
||||||
onEdit?: (id: string) => void;
|
|
||||||
onDelete?: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SortableItem({
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
icon,
|
|
||||||
thumbnail,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
}: SortableItemProps) {
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({ id });
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-between px-4 py-3 border border-grayScale-200 rounded-[6px] mb-2 bg-white transition-all duration-200 group/item",
|
|
||||||
isDragging && "opacity-50 border-dashed z-50 shadow-sm",
|
|
||||||
!isDragging && "hover:border-brand-200 hover:shadow-sm",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<button
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
className="cursor-grab active:cursor-grabbing p-1 text-grayScale-300 hover:text-brand-500 transition-colors"
|
|
||||||
>
|
|
||||||
<GripVertical className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* Thumbnail/Icon Container */}
|
|
||||||
<div className="h-10 w-10 shrink-0 rounded-[4px] bg-grayScale-50 border border-grayScale-100 flex items-center justify-center overflow-hidden">
|
|
||||||
{thumbnail ? (
|
|
||||||
<img
|
|
||||||
src={thumbnail}
|
|
||||||
alt={name}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="text-grayScale-400 group-hover/item:text-brand-500 transition-colors">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[14px] font-bold text-grayScale-800 leading-tight">
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-opacity">
|
|
||||||
<button
|
|
||||||
onClick={() => onEdit?.(id)}
|
|
||||||
className="p-2 text-grayScale-400 rounded-[4px] transition-all"
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<Edit2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onDelete?.(id)}
|
|
||||||
className="p-2 text-grayScale-400 rounded-[4px] transition-all"
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DraggableListProps {
|
|
||||||
items: BaseItem[];
|
|
||||||
onReorder: (activeId: string, overId: string) => void;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
onEdit?: (id: string) => void;
|
|
||||||
onDelete?: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DraggableList({
|
|
||||||
items,
|
|
||||||
onReorder,
|
|
||||||
icon,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
}: DraggableListProps) {
|
|
||||||
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
||||||
useSensor(KeyboardSensor, {
|
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDragStart = (event: DragStartEvent) =>
|
|
||||||
setActiveId(event.active.id);
|
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
if (over && active.id !== over.id) {
|
|
||||||
onReorder(active.id as string, over.id as string);
|
|
||||||
}
|
|
||||||
setActiveId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const activeItem = items.find((i) => i.id === activeId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<SortableContext items={items} strategy={verticalListSortingStrategy}>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{items.map((item) => (
|
|
||||||
<SortableItem
|
|
||||||
key={item.id}
|
|
||||||
id={item.id}
|
|
||||||
name={item.name}
|
|
||||||
thumbnail={item.thumbnail}
|
|
||||||
icon={icon}
|
|
||||||
onEdit={onEdit}
|
|
||||||
onDelete={onDelete}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
<DragOverlay>
|
|
||||||
{activeItem ? (
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 bg-white border border-brand-300 shadow-lg rounded-[6px] opacity-90 cursor-grabbing">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="p-1 ">
|
|
||||||
<GripVertical className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="h-10 w-10 shrink-0 rounded-[4px] bg-grayScale-50 border border-grayScale-100 flex items-center justify-center overflow-hidden">
|
|
||||||
{activeItem.thumbnail ? (
|
|
||||||
<img
|
|
||||||
src={activeItem.thumbnail}
|
|
||||||
alt={activeItem.name}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="text-brand-500">{icon}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-[14px] font-bold text-grayScale-800">
|
|
||||||
{activeItem.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</DragOverlay>
|
|
||||||
</DndContext>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SectionProps {
|
|
||||||
title: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
isOpen: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HierarchySection({
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
isOpen,
|
|
||||||
onToggle,
|
|
||||||
children,
|
|
||||||
}: SectionProps) {
|
|
||||||
return (
|
|
||||||
<div className="border border-grayScale-100 rounded-xl mb-3 overflow-hidden transition-all duration-300 bg-white">
|
|
||||||
<button
|
|
||||||
onClick={onToggle}
|
|
||||||
className={cn(
|
|
||||||
"w-full flex items-center justify-between px-5 py-4 transition-colors",
|
|
||||||
isOpen ? "bg-grayScale-50" : "hover:bg-grayScale-25",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"p-2 rounded-lg transition-colors",
|
|
||||||
isOpen
|
|
||||||
? "bg-brand-300 text-white"
|
|
||||||
: "bg-grayScale-50 text-grayScale-500",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-[15px] font-bold",
|
|
||||||
isOpen ? "text-grayScale-900" : "text-grayScale-700",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{isOpen ? (
|
|
||||||
<ChevronDown className="h-5 w-5 text-grayScale-400" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-5 w-5 text-grayScale-400" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
|
||||||
isOpen ? "max-h-[1000px] opacity-100 p-5 pt-0" : "max-h-0 opacity-0",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="pt-4 border-t border-grayScale-200">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContentHierarchyList() {
|
|
||||||
const [programs, setPrograms] = useState<Program[]>(initialPrograms);
|
|
||||||
const [courses, setCourses] = useState<Course[]>(initialCourses);
|
|
||||||
const [modules, setModules] = useState<Module[]>(initialModules);
|
|
||||||
const [lessons, setLessons] = useState<Lesson[]>(initialLessons);
|
|
||||||
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
|
|
||||||
program: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleSection = (id: string) => {
|
|
||||||
setOpenSections((prev) => ({ ...prev, [id]: !prev[id] }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const reorder = <T extends BaseItem>(
|
|
||||||
list: T[],
|
|
||||||
setList: React.Dispatch<React.SetStateAction<T[]>>,
|
|
||||||
activeId: string,
|
|
||||||
overId: string,
|
|
||||||
) => {
|
|
||||||
const oldIndex = list.findIndex((i) => i.id === activeId);
|
|
||||||
const newIndex = list.findIndex((i) => i.id === overId);
|
|
||||||
if (oldIndex !== -1 && newIndex !== -1) {
|
|
||||||
setList(arrayMove(list, oldIndex, newIndex));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (type: ItemType, id: string) => {
|
|
||||||
console.log(`Edit ${type}: ${id}`);
|
|
||||||
// Logic for opening edit modal would go here
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (type: ItemType, id: string) => {
|
|
||||||
if (!window.confirm(`Are you sure you want to delete this ${type}?`))
|
|
||||||
return;
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case "program":
|
|
||||||
setPrograms((prev) => prev.filter((p) => p.id !== id));
|
|
||||||
break;
|
|
||||||
case "course":
|
|
||||||
setCourses((prev) => prev.filter((c) => c.id !== id));
|
|
||||||
break;
|
|
||||||
case "module":
|
|
||||||
setModules((prev) => prev.filter((m) => m.id !== id));
|
|
||||||
break;
|
|
||||||
case "lesson":
|
|
||||||
setLessons((prev) => prev.filter((l) => l.id !== id));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
setPrograms(initialPrograms);
|
|
||||||
setCourses(initialCourses);
|
|
||||||
setModules(initialModules);
|
|
||||||
setLessons(initialLessons);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-[#ffffff] rounded-2xl p-6 border border-grayScale-100 mb-8 shadow-sm">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-[16px] font-bold text-grayScale-900">
|
|
||||||
Content Hierarchy
|
|
||||||
</h3>
|
|
||||||
<p className="text-[12px] text-grayScale-500 mt-1">
|
|
||||||
Manage the ordering and structure of your educational content
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleReset}
|
|
||||||
className="text-[13px] font-bold text-brand-300 hover:text-brand-400 transition-colors flex items-center gap-2 group"
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-4 w-4 transition-transform group-hover:rotate-[-45deg]" />
|
|
||||||
Reset All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Program Section */}
|
|
||||||
<HierarchySection
|
|
||||||
title="Programs"
|
|
||||||
icon={<LayoutGrid className="h-5 w-5" />}
|
|
||||||
isOpen={openSections.program}
|
|
||||||
onToggle={() => toggleSection("program")}
|
|
||||||
>
|
|
||||||
<DraggableList
|
|
||||||
items={programs}
|
|
||||||
onReorder={(active, over) =>
|
|
||||||
reorder(programs, setPrograms, active, over)
|
|
||||||
}
|
|
||||||
icon={<LayoutGrid className="h-4 w-4" />}
|
|
||||||
onEdit={(id) => handleEdit("program", id)}
|
|
||||||
onDelete={(id) => handleDelete("program", id)}
|
|
||||||
/>
|
|
||||||
</HierarchySection>
|
|
||||||
|
|
||||||
{/* Course Section */}
|
|
||||||
<HierarchySection
|
|
||||||
title="Courses"
|
|
||||||
icon={<BookOpen className="h-5 w-5" />}
|
|
||||||
isOpen={openSections.course}
|
|
||||||
onToggle={() => toggleSection("course")}
|
|
||||||
>
|
|
||||||
{programs.map((program) => {
|
|
||||||
const programCourses = courses.filter(
|
|
||||||
(c) => c.programId === program.id,
|
|
||||||
);
|
|
||||||
if (programCourses.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<div key={program.id} className="mb-4 last:mb-0">
|
|
||||||
<h4 className="text-[12px] font-bold text-grayScale-400 uppercase tracking-wider mb-2 px-1">
|
|
||||||
{program.name}
|
|
||||||
</h4>
|
|
||||||
<DraggableList
|
|
||||||
items={programCourses}
|
|
||||||
onReorder={(active, over) =>
|
|
||||||
reorder(courses, setCourses, active, over)
|
|
||||||
}
|
|
||||||
icon={<BookOpen className="h-4 w-4" />}
|
|
||||||
onEdit={(id) => handleEdit("course", id)}
|
|
||||||
onDelete={(id) => handleDelete("course", id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</HierarchySection>
|
|
||||||
|
|
||||||
{/* Module Section */}
|
|
||||||
<HierarchySection
|
|
||||||
title="Modules"
|
|
||||||
icon={<Layers className="h-5 w-5" />}
|
|
||||||
isOpen={openSections.module}
|
|
||||||
onToggle={() => toggleSection("module")}
|
|
||||||
>
|
|
||||||
{courses.map((course) => {
|
|
||||||
const courseModules = modules.filter(
|
|
||||||
(m) => m.courseId === course.id,
|
|
||||||
);
|
|
||||||
if (courseModules.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<div key={course.id} className="mb-4 last:mb-0">
|
|
||||||
<h4 className="text-[12px] font-bold text-grayScale-400 uppercase tracking-wider mb-2 px-1">
|
|
||||||
{course.name}
|
|
||||||
</h4>
|
|
||||||
<DraggableList
|
|
||||||
items={courseModules}
|
|
||||||
onReorder={(active, over) =>
|
|
||||||
reorder(modules, setModules, active, over)
|
|
||||||
}
|
|
||||||
icon={<Layers className="h-4 w-4" />}
|
|
||||||
onEdit={(id) => handleEdit("module", id)}
|
|
||||||
onDelete={(id) => handleDelete("module", id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</HierarchySection>
|
|
||||||
|
|
||||||
{/* Lesson Section */}
|
|
||||||
<HierarchySection
|
|
||||||
title="Lessons"
|
|
||||||
icon={<PlayCircle className="h-5 w-5" />}
|
|
||||||
isOpen={openSections.lesson}
|
|
||||||
onToggle={() => toggleSection("lesson")}
|
|
||||||
>
|
|
||||||
{modules.map((module) => {
|
|
||||||
const moduleLessons = lessons.filter(
|
|
||||||
(l) => l.moduleId === module.id,
|
|
||||||
);
|
|
||||||
if (moduleLessons.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<div key={module.id} className="mb-4 last:mb-0">
|
|
||||||
<h4 className="text-[12px] font-bold text-grayScale-400 uppercase tracking-wider mb-2 px-1">
|
|
||||||
{module.name}
|
|
||||||
</h4>
|
|
||||||
<DraggableList
|
|
||||||
items={moduleLessons}
|
|
||||||
onReorder={(active, over) =>
|
|
||||||
reorder(lessons, setLessons, active, over)
|
|
||||||
}
|
|
||||||
icon={<PlayCircle className="h-4 w-4" />}
|
|
||||||
onEdit={(id) => handleEdit("lesson", id)}
|
|
||||||
onDelete={(id) => handleDelete("lesson", id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</HierarchySection>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,481 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,215 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
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,43 +1,14 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { MoreVertical, Edit2, Play } from "lucide-react";
|
||||||
import { MoreVertical, Edit2, Play, Pencil, Trash2 } from "lucide-react";
|
|
||||||
import { Button } from "../../../components/ui/button";
|
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 { cn } from "../../../lib/utils";
|
||||||
import {
|
|
||||||
applyShortPreviewToEmbedUrl,
|
|
||||||
DEFAULT_PREVIEW_MAX_SECONDS,
|
|
||||||
formatPreviewLength,
|
|
||||||
getVideoPreview,
|
|
||||||
} from "../../../lib/videoPreview";
|
|
||||||
import { PreviewLimitedFileVideo } from "./PreviewLimitedFileVideo";
|
|
||||||
|
|
||||||
interface VideoCardProps {
|
interface VideoCardProps {
|
||||||
id?: string | number;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
/** Omits the duration chip when not provided (e.g. API has no length yet). */
|
duration: string;
|
||||||
duration?: string;
|
status: "Draft" | "Published";
|
||||||
/** When omitted, shows a neutral "Lesson" chip and no Publish button. */
|
thumbnailGradient: string;
|
||||||
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;
|
onEdit?: () => void;
|
||||||
onDelete?: () => void;
|
|
||||||
onPublish?: () => void;
|
onPublish?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,330 +16,84 @@ export function VideoCard({
|
||||||
title,
|
title,
|
||||||
duration,
|
duration,
|
||||||
status,
|
status,
|
||||||
thumbnailGradient = "from-[#CBD5E1] to-[#94A3B8]",
|
thumbnailGradient,
|
||||||
thumbnailUrl,
|
|
||||||
videoUrl,
|
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
|
||||||
onPublish,
|
onPublish,
|
||||||
hoverModuleActions = false,
|
|
||||||
}: VideoCardProps) {
|
}: 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 (
|
return (
|
||||||
<div
|
<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">
|
||||||
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 */}
|
{/* Thumbnail */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative h-44 w-full overflow-hidden",
|
"relative h-44 w-full bg-gradient-to-br",
|
||||||
useGradient && "bg-gradient-to-br",
|
thumbnailGradient,
|
||||||
useGradient && thumbnailGradient,
|
|
||||||
!useGradient && "bg-grayScale-100",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{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>
|
|
||||||
) : null}
|
|
||||||
{!useGradient && thumbnailUrl ? (
|
|
||||||
<img
|
|
||||||
src={thumbnailUrl}
|
|
||||||
alt=""
|
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
|
||||||
onError={() => setThumbFailed(true)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{/* Duration Badge */}
|
{/* Duration Badge */}
|
||||||
{duration ? (
|
<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">
|
||||||
<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}
|
||||||
{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" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</div>
|
||||||
{/* 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>
|
</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 */}
|
{/* Content */}
|
||||||
<div className="p-5 space-y-4 flex-1 flex flex-col">
|
<div className="p-5 space-y-4 flex-1 flex flex-col">
|
||||||
<div
|
<div className="flex items-center justify-between">
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2",
|
|
||||||
hoverModuleActions ? "justify-start" : "justify-between",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Status Badge */}
|
{/* Status Badge */}
|
||||||
{status ? (
|
<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]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider border min-w-0",
|
"h-1.5 w-1.5 rounded-full",
|
||||||
status === "Published"
|
status === "Published" ? "bg-[#10B981]" : "bg-[#9CA3AF]",
|
||||||
? "bg-[#ECFDF5] text-[#059669] border-[#D1FAE5]"
|
|
||||||
: "bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]",
|
|
||||||
)}
|
)}
|
||||||
>
|
/>
|
||||||
<div
|
{status}
|
||||||
className={cn(
|
</div>
|
||||||
"h-1.5 w-1.5 rounded-full flex-shrink-0",
|
{/* Menu */}
|
||||||
status === "Published" ? "bg-[#10B981]" : "bg-[#9CA3AF]",
|
<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>
|
||||||
{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>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-[16px] font-medium text-grayScale-900 line-clamp-2 leading-snug">
|
<h3 className="text-[16px] font-medium text-grayScale-900 line-clamp-2 leading-snug">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Actions (footer) — not used for API lesson cards with hover tools */}
|
{/* Actions */}
|
||||||
{!hoverModuleActions ? (
|
<div className="pt-2 space-y-3 mt-auto">
|
||||||
<div className="pt-2 space-y-3 mt-auto">
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="outline"
|
onClick={onEdit}
|
||||||
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"
|
||||||
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" />
|
||||||
<Edit2 className="h-4 w-4" />
|
Edit
|
||||||
Edit
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
{status ? (
|
disabled={status === "Published"}
|
||||||
<Button
|
onClick={onPublish}
|
||||||
disabled={status === "Published"}
|
className={cn(
|
||||||
onClick={onPublish}
|
"w-full h-10 rounded-xl font-bold transition-all shadow-sm",
|
||||||
className={cn(
|
status === "Published"
|
||||||
"w-full h-10 rounded-xl font-bold transition-all shadow-sm",
|
? "bg-[#E9D5E5] text-white opacity-100 cursor-default"
|
||||||
status === "Published"
|
: "bg-brand-500 text-white hover:bg-brand-600 shadow-brand-500/10",
|
||||||
? "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>
|
||||||
{status === "Published" ? "Published" : "Publish"}
|
</div>
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Play, Pause, X } from "lucide-react";
|
import { Play, Pause, X } from "lucide-react";
|
||||||
import { cn } from "../../../../lib/utils";
|
import { cn } from "../../../../lib/utils";
|
||||||
import { resolveDisplayMediaUrl } from "../../../../lib/mediaUrl";
|
|
||||||
|
|
||||||
interface VoicePromptProps {
|
interface VoicePromptProps {
|
||||||
/** Either a URL/path to the audio file, or a filename string (for display-only mode) */
|
/** Either a URL/path to the audio file, or a filename string (for display-only mode) */
|
||||||
|
|
@ -22,34 +21,13 @@ export function VoicePrompt({
|
||||||
const [bars, setBars] = useState<number[]>([]);
|
const [bars, setBars] = useState<number[]>([]);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [progress, setProgress] = useState(0); // 0–1
|
const [progress, setProgress] = useState(0); // 0–1
|
||||||
const [playableSrc, setPlayableSrc] = useState("");
|
|
||||||
|
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const rafRef = useRef<number | null>(null);
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
(async () => {
|
|
||||||
const raw = src?.trim() || "";
|
|
||||||
if (!raw) {
|
|
||||||
setPlayableSrc("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const resolved = await resolveDisplayMediaUrl(raw);
|
|
||||||
if (!cancelled) setPlayableSrc(resolved || raw);
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) setPlayableSrc(raw);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [src]);
|
|
||||||
|
|
||||||
// ─── Decode audio and build waveform bars ───────────────────────────────────
|
// ─── Decode audio and build waveform bars ───────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!playableSrc) {
|
if (!src) {
|
||||||
// No real audio — generate plausible static bars
|
// No real audio — generate plausible static bars
|
||||||
setBars(generateFakeBars());
|
setBars(generateFakeBars());
|
||||||
return;
|
return;
|
||||||
|
|
@ -58,7 +36,7 @@ export function VoicePrompt({
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const audioCtx = new AudioContext();
|
const audioCtx = new AudioContext();
|
||||||
|
|
||||||
fetch(playableSrc)
|
fetch(src)
|
||||||
.then((r) => r.arrayBuffer())
|
.then((r) => r.arrayBuffer())
|
||||||
.then((buf) => audioCtx.decodeAudioData(buf))
|
.then((buf) => audioCtx.decodeAudioData(buf))
|
||||||
.then((decoded) => {
|
.then((decoded) => {
|
||||||
|
|
@ -84,15 +62,7 @@ export function VoicePrompt({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [playableSrc]);
|
}, [src]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
audioRef.current?.pause();
|
|
||||||
audioRef.current = null;
|
|
||||||
setIsPlaying(false);
|
|
||||||
setProgress(0);
|
|
||||||
stopProgressLoop();
|
|
||||||
}, [playableSrc]);
|
|
||||||
|
|
||||||
// ─── Sync progress while playing ────────────────────────────────────────────
|
// ─── Sync progress while playing ────────────────────────────────────────────
|
||||||
const startProgressLoop = () => {
|
const startProgressLoop = () => {
|
||||||
|
|
@ -114,10 +84,10 @@ export function VoicePrompt({
|
||||||
|
|
||||||
// ─── Play / Pause ────────────────────────────────────────────────────────────
|
// ─── Play / Pause ────────────────────────────────────────────────────────────
|
||||||
const handlePlayPause = () => {
|
const handlePlayPause = () => {
|
||||||
if (!playableSrc) return;
|
if (!src) return;
|
||||||
|
|
||||||
if (!audioRef.current) {
|
if (!audioRef.current) {
|
||||||
audioRef.current = new Audio(playableSrc);
|
audioRef.current = new Audio(src);
|
||||||
audioRef.current.onended = () => {
|
audioRef.current.onended = () => {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
|
|
|
||||||
|
|
@ -1,167 +1,86 @@
|
||||||
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 {
|
import {
|
||||||
applyShortPreviewToEmbedUrl,
|
Rocket,
|
||||||
DEFAULT_PREVIEW_MAX_SECONDS,
|
Edit2,
|
||||||
formatPreviewLength,
|
Layout,
|
||||||
getVideoPreview,
|
Volume2,
|
||||||
resolveThumbnailForPreview,
|
Settings,
|
||||||
} from "../../../../lib/videoPreview";
|
Maximize2,
|
||||||
import { PreviewLimitedFileVideo } from "../PreviewLimitedFileVideo";
|
} from "lucide-react";
|
||||||
|
import { Button } from "../../../../components/ui/button";
|
||||||
|
|
||||||
interface ReviewPublishStepProps {
|
interface ReviewPublishStepProps {
|
||||||
formData: AddLessonFormData;
|
formData: any;
|
||||||
prevStep: () => void;
|
prevStep: () => void;
|
||||||
onPublish: () => void;
|
setIsPublished: (val: boolean) => void;
|
||||||
publishing: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncate(s: string, max: number): string {
|
|
||||||
if (s.length <= max) return s;
|
|
||||||
return `${s.slice(0, max)}…`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReviewPublishStep({
|
export function ReviewPublishStep({
|
||||||
formData,
|
formData,
|
||||||
prevStep,
|
prevStep,
|
||||||
onPublish,
|
setIsPublished,
|
||||||
publishing,
|
|
||||||
}: ReviewPublishStepProps) {
|
}: 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 (
|
return (
|
||||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500 pb-20">
|
<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="bg-white rounded-[16px] border border-grayScale-50 shadow-sm overflow-hidden">
|
||||||
<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">
|
<div className="px-8 py-5 border-b border-grayScale-50 flex items-center justify-between bg-white">
|
||||||
<h3 className="text-[17px] font-bold text-grayScale-900">
|
<h3 className="text-[17px] font-bold text-grayScale-900">
|
||||||
Media preview
|
Video Preview
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs font-medium text-grayScale-500">
|
<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">
|
||||||
Video: short clip (first {previewLengthLabel} only)
|
PROCESSED
|
||||||
</p>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-8">
|
<div className="p-10 flex items-center justify-center bg-[#F8FAFC]/30">
|
||||||
<div className="flex flex-col gap-10 xl:flex-row xl:items-start xl:gap-10">
|
<div className="relative w-full max-w-4xl aspect-video rounded-[12px] overflow-hidden bg-black shadow-2xl group border-4 border-white">
|
||||||
{/* Video preview */}
|
{/* Mock Player Control Overlays */}
|
||||||
<div className="min-w-0 flex-1 space-y-3">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
<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">
|
||||||
Video
|
<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" />
|
||||||
</span>
|
</div>
|
||||||
{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>
|
|
||||||
) : (
|
|
||||||
<p className="text-grayScale-400 text-sm">—</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Thumbnail preview */}
|
{/* Bottom Controls — Matching Image 1884 */}
|
||||||
<div className="w-full shrink-0 space-y-3 xl:max-w-[360px]">
|
<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">
|
||||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
{/* Row 1: Seeker and Timestamps */}
|
||||||
Thumbnail
|
<div className="flex items-center gap-4 text-white">
|
||||||
</span>
|
<span className="text-[13px] font-medium opacity-90">0:00</span>
|
||||||
{formData.thumbnailUrl && thumbSrc ? (
|
<div className="flex-1 h-1 bg-white/20 rounded-full relative cursor-pointer overflow-hidden group/seeker">
|
||||||
<div className="space-y-3">
|
<div
|
||||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-100 shadow-sm">
|
className="absolute left-0 top-0 bottom-0 bg-brand-500 rounded-full"
|
||||||
<div className="relative aspect-video w-full max-w-md">
|
style={{ width: "40%" }}
|
||||||
{!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>
|
||||||
) : (
|
<span className="text-[13px] font-medium opacity-90">
|
||||||
<p className="text-grayScale-400 text-sm">—</p>
|
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>
|
||||||
|
</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" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Content Details Card */}
|
||||||
<div className="bg-white rounded-[16px] border border-grayScale-50 shadow-sm overflow-hidden">
|
<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 items-center justify-between bg-white">
|
||||||
<h3 className="text-[16px] font-bold text-grayScale-900">
|
<h3 className="text-[16px] font-bold text-grayScale-900">
|
||||||
Content details
|
Content Details
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={prevStep}
|
onClick={prevStep}
|
||||||
className="flex items-center gap-2 text-brand-500 font-bold text-sm hover:opacity-80 transition-opacity"
|
className="flex items-center gap-2 text-brand-500 font-bold text-sm hover:opacity-80 transition-opacity"
|
||||||
>
|
>
|
||||||
|
|
@ -171,29 +90,70 @@ export function ReviewPublishStep({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8 space-y-10">
|
<div className="p-8 space-y-10">
|
||||||
<div className="space-y-2">
|
{/* Metadata Grid */}
|
||||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||||
Title
|
<div className="space-y-2">
|
||||||
</span>
|
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||||
<p className="text-[15px] font-medium text-grayScale-900">
|
TITLE
|
||||||
{formData.title || "—"}
|
</span>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{/* Description Section */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||||
Description
|
DESCRIPTION
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
className="text-[14px] text-grayScale-600 leading-relaxed max-w-4xl"
|
className="text-[14px] text-grayScale-600 leading-relaxed max-w-4xl"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html:
|
__html:
|
||||||
formData.description || "<p class='text-grayScale-400'>—</p>",
|
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.",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Gradient Divider */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 flex items-center"
|
className="absolute inset-0 flex items-center"
|
||||||
|
|
@ -204,17 +164,18 @@ export function ReviewPublishStep({
|
||||||
<div className="relative flex justify-center">
|
<div className="relative flex justify-center">
|
||||||
<div
|
<div
|
||||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
className="h-[0.5px] w-full opacity-20 rounded-full"
|
||||||
style={{ background: "gray" }}
|
style={{
|
||||||
|
background: "gray",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="px-8 py-6 border-t border-grayScale-50 flex items-center justify-between bg-white">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={prevStep}
|
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"
|
className="h-10 px-8 rounded-xl border-grayScale-200 font-bold text-grayScale-600 hover:bg-grayScale-50 transition-all shadow-sm"
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
|
|
@ -222,24 +183,17 @@ export function ReviewPublishStep({
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
|
||||||
variant="outline"
|
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"
|
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>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
onClick={() => setIsPublished(true)}
|
||||||
onClick={onPublish}
|
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={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" />
|
<Rocket className="h-4 w-4" />
|
||||||
{publishing ? "Creating…" : "Create lesson"}
|
Publish Now
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,32 @@
|
||||||
|
import { useRef, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
useRef,
|
Video,
|
||||||
useEffect,
|
List,
|
||||||
type Dispatch,
|
Link as LinkIcon,
|
||||||
type SetStateAction,
|
Lightbulb,
|
||||||
} from "react";
|
ChevronRight,
|
||||||
import { List, Link as LinkIcon, Lightbulb, ArrowRight } from "lucide-react";
|
ImageIcon,
|
||||||
import { toast } from "sonner";
|
ArrowRight,
|
||||||
|
} from "lucide-react";
|
||||||
import { Button } from "../../../../components/ui/button";
|
import { Button } from "../../../../components/ui/button";
|
||||||
import { Input } from "../../../../components/ui/input";
|
import { Input } from "../../../../components/ui/input";
|
||||||
import type { AddLessonFormData } from "../../AddVideoFlow";
|
import { Select } from "../../../../components/ui/select";
|
||||||
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 {
|
interface VideoDetailStepProps {
|
||||||
formData: AddLessonFormData;
|
formData: any;
|
||||||
setFormData: Dispatch<SetStateAction<AddLessonFormData>>;
|
setFormData: (data: any) => void;
|
||||||
onContinue: () => void;
|
nextStep: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoDetailStep({
|
export function VideoDetailStep({
|
||||||
formData,
|
formData,
|
||||||
setFormData,
|
setFormData,
|
||||||
onContinue,
|
nextStep,
|
||||||
}: VideoDetailStepProps) {
|
}: VideoDetailStepProps) {
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
const isInternalChange = useRef(false);
|
const isInternalChange = useRef(false);
|
||||||
|
|
||||||
|
// Initialize editor content only once or when needed from outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editorRef.current && !isInternalChange.current) {
|
if (editorRef.current && !isInternalChange.current) {
|
||||||
editorRef.current.innerHTML = formData.description || "";
|
editorRef.current.innerHTML = formData.description || "";
|
||||||
|
|
@ -45,10 +41,8 @@ export function VideoDetailStep({
|
||||||
const syncState = () => {
|
const syncState = () => {
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
isInternalChange.current = true;
|
isInternalChange.current = true;
|
||||||
setFormData((prev) => ({
|
setFormData({ ...formData, description: editorRef.current.innerHTML });
|
||||||
...prev,
|
// Reset after a short delay to allow exterior updates if any (e.g., from step change)
|
||||||
description: editorRef.current!.innerHTML,
|
|
||||||
}));
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isInternalChange.current = false;
|
isInternalChange.current = false;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
@ -59,57 +53,50 @@ export function VideoDetailStep({
|
||||||
syncState();
|
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 (
|
return (
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700 max-w-[1200px] mx-auto pb-20">
|
<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">
|
<div className="bg-white rounded-[24px] border border-grayScale-50 p-10 shadow-sm space-y-8">
|
||||||
<div className="space-y-3">
|
{/* 1. Upload Video Section */}
|
||||||
|
<div className="space-y-6">
|
||||||
<h3 className="text-[20px] font-bold text-grayScale-900 ml-1">
|
<h3 className="text-[20px] font-bold text-grayScale-900 ml-1">
|
||||||
Video
|
Upload Video
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-grayScale-500 ml-1 max-w-2xl">
|
<div className="relative group cursor-pointer">
|
||||||
Upload a file or paste a link (Vimeo, hosted file, etc.). Files are
|
<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">
|
||||||
sent to your storage via{" "}
|
<div className="h-16 w-16 rounded-full bg-white shadow-sm flex items-center justify-center mb-6">
|
||||||
<code className="rounded bg-grayScale-100 px-1 text-[11px]">
|
<div className="h-10 w-10 rounded-full bg-[#FAF5FF] flex items-center justify-center">
|
||||||
POST /files/upload
|
<div className="h-6 w-6 relative flex items-center justify-center">
|
||||||
</code>
|
<div className="absolute inset-0 bg-brand-500/10 rounded-full blur-sm" />
|
||||||
.
|
<Video className="h-5 w-5 text-brand-500 relative" />
|
||||||
</p>
|
</div>
|
||||||
<LessonMediaUploadField
|
</div>
|
||||||
kind="video"
|
</div>
|
||||||
value={formData.videoUrl}
|
<h4 className="text-[17px] text-grayScale-900 mb-2">
|
||||||
onChange={(v) =>
|
Drag and drop video files here
|
||||||
setFormData((prev) => ({ ...prev, videoUrl: v }))
|
</h4>
|
||||||
}
|
<p className="text-grayScale-400 font-medium text-[13px] mb-8">
|
||||||
/>
|
MP4, MOV, WebM. Max size 2GB.
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
|
{/* Gradient Divider */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 flex items-center"
|
className="absolute inset-0 flex items-center"
|
||||||
|
|
@ -120,57 +107,75 @@ export function VideoDetailStep({
|
||||||
<div className="relative flex justify-center">
|
<div className="relative flex justify-center">
|
||||||
<div
|
<div
|
||||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
className="h-[0.5px] w-full opacity-20 rounded-full"
|
||||||
style={{ background: "gray" }}
|
style={{
|
||||||
|
background: "gray",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Form & Side Panel Grid */}
|
||||||
<div className="flex flex-col lg:flex-row gap-12 items-start">
|
<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="flex-1 w-full space-y-10">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
|
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
|
||||||
Lesson title
|
Video Title
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="e.g. Introduction to Past Tense"
|
placeholder="e.g., Introduction to Past Tense Verbs"
|
||||||
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"
|
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}
|
value={formData.title}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData((prev) => ({ ...prev, title: e.target.value }))
|
setFormData({ ...formData, title: e.target.value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-3">
|
||||||
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
|
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
|
||||||
Description
|
Description
|
||||||
</label>
|
</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">
|
<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 bg-[#F8FAFC]">
|
||||||
<div className="flex items-center gap-1 w-fit bg-transparent px-2 py-1 rounded-lg">
|
<div className="flex items-center gap-1 w-fit bg-transparent px-2 py-1 rounded-lg">
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={() => handleCommand("bold")}
|
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"
|
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
|
B
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={() => handleCommand("italic")}
|
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"
|
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
|
I
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={() => handleCommand("insertUnorderedList")}
|
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"
|
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" />
|
<List className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const url = prompt("Enter URL:");
|
const url = prompt("Enter URL:");
|
||||||
if (url) handleCommand("createLink", url);
|
if (url) handleCommand("createLink", url);
|
||||||
|
|
@ -183,9 +188,12 @@ export function VideoDetailStep({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative p-6 flex-1">
|
<div className="relative p-6 flex-1">
|
||||||
{isDescriptionEmpty(formData.description) && (
|
{(!formData.description ||
|
||||||
|
formData.description === "<br>" ||
|
||||||
|
formData.description === "" ||
|
||||||
|
formData.description === "<div><br></div>") && (
|
||||||
<div className="absolute top-6 left-6 text-grayScale-300 font-medium text-[15px] pointer-events-none">
|
<div className="absolute top-6 left-6 text-grayScale-300 font-medium text-[15px] pointer-events-none">
|
||||||
What will students learn in this lesson?
|
Provide a brief summary of what the student will learn...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
|
|
@ -199,44 +207,59 @@ export function VideoDetailStep({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full lg:w-[360px] space-y-5">
|
{/* Right Column: Thumbnail, Pro Tip */}
|
||||||
<LessonMediaUploadField
|
<div className="w-full lg:w-[320px] space-y-5">
|
||||||
kind="thumbnail"
|
{/* Thumbnail Section */}
|
||||||
value={formData.thumbnailUrl}
|
<div className="space-y-4">
|
||||||
onChange={(v) =>
|
<div className="space-y-1 ml-1">
|
||||||
setFormData((prev) => ({ ...prev, thumbnailUrl: v }))
|
<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="bg-brand-500/5 flex items-start gap-3 rounded-xl border border-[#F3E8FF] p-6 space-y-3">
|
<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="flex items-center gap-3">
|
||||||
<div className="h-8 w-8 flex-shrink-0 flex items-center justify-center">
|
<div className="h-8 w-8 flex-shrink-0 flex items-center justify-center">
|
||||||
<Lightbulb
|
<Lightbulb className="h-4 w-4 text-brand-50" fill="#A855F7" />
|
||||||
className="h-4 w-4 text-brand-50"
|
|
||||||
fill="#A855F7"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative top-[-10px]">
|
<div className="relative top-[-10px]">
|
||||||
<h3 className="text-[14px] font-bold text-grayScale-900">
|
<h3 className="text-[14px] font-bold text-grayScale-900">
|
||||||
Pro tip
|
Pro Tip
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[12px] text-grayScale-700 font-medium leading-relaxed">
|
<p className="text-[12px] text-grayScale-700 font-medium leading-relaxed">
|
||||||
Use clear titles and a thumbnail that matches the lesson. The
|
Short, descriptive titles work best. Include keywords like
|
||||||
lesson is created with{" "}
|
"Grammar" or "Vocabulary" to help students find your content.
|
||||||
<code className="rounded bg-white/80 px-1 text-[10px]">
|
|
||||||
POST /modules/:moduleId/lessons
|
|
||||||
</code>{" "}
|
|
||||||
when you publish.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-5 border-t border-grayScale-200 flex items-center justify-end">
|
{/* 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>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
onClick={nextStep}
|
||||||
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"
|
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
|
Continue
|
||||||
|
|
|
||||||
|
|
@ -95,13 +95,12 @@ function getStatusConfig(status: string): {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getIssueTypeConfig(type: string | null | undefined): {
|
function getIssueTypeConfig(type: string): {
|
||||||
label: string;
|
label: string;
|
||||||
classes: string;
|
classes: string;
|
||||||
icon: typeof Bug;
|
icon: typeof Bug;
|
||||||
} {
|
} {
|
||||||
const t = String(type ?? "").trim();
|
switch (type) {
|
||||||
switch (t) {
|
|
||||||
case "bug":
|
case "bug":
|
||||||
return {
|
return {
|
||||||
label: "Bug",
|
label: "Bug",
|
||||||
|
|
@ -134,7 +133,7 @@ function getIssueTypeConfig(type: string | null | undefined): {
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
label: t ? t.charAt(0).toUpperCase() + t.slice(1) : "Other",
|
label: type.charAt(0).toUpperCase() + type.slice(1),
|
||||||
classes: "bg-grayScale-100 text-grayScale-600 border-grayScale-200",
|
classes: "bg-grayScale-100 text-grayScale-600 border-grayScale-200",
|
||||||
icon: HelpCircle,
|
icon: HelpCircle,
|
||||||
};
|
};
|
||||||
|
|
@ -174,10 +173,8 @@ function getRelativeTime(dateStr: string): string {
|
||||||
return formatDate(dateStr);
|
return formatDate(dateStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRoleLabel(role: string | null | undefined): string {
|
function formatRoleLabel(role: string): string {
|
||||||
const r = String(role ?? "").trim();
|
return role
|
||||||
if (!r) return "—";
|
|
||||||
return r
|
|
||||||
.split("_")
|
.split("_")
|
||||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
@ -224,9 +221,8 @@ export function IssuesPage() {
|
||||||
offset: (page - 1) * pageSize,
|
offset: (page - 1) * pageSize,
|
||||||
};
|
};
|
||||||
const res = await getIssues(filters);
|
const res = await getIssues(filters);
|
||||||
const payload = res.data?.data;
|
setIssues(res.data.data.issues);
|
||||||
setIssues(Array.isArray(payload?.issues) ? payload.issues : []);
|
setTotalCount(res.data.data.total_count);
|
||||||
setTotalCount(typeof payload?.total_count === "number" ? payload.total_count : 0);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch issues:", error);
|
console.error("Failed to fetch issues:", error);
|
||||||
setIssues([]);
|
setIssues([]);
|
||||||
|
|
@ -245,7 +241,7 @@ export function IssuesPage() {
|
||||||
setDetailLoading(true);
|
setDetailLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await getIssueById(issueId);
|
const res = await getIssueById(issueId);
|
||||||
setSelectedIssue(res.data?.data ?? null);
|
setSelectedIssue(res.data.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch issue detail:", error);
|
console.error("Failed to fetch issue detail:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -309,15 +305,16 @@ export function IssuesPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Client-side filtering (status, type, search)
|
// Client-side filtering (status, type, search)
|
||||||
const filteredIssues = (Array.isArray(issues) ? issues : []).filter((issue) => {
|
const filteredIssues = issues.filter((issue) => {
|
||||||
if (statusFilter && issue.status !== statusFilter) return false;
|
if (statusFilter && issue.status !== statusFilter) return false;
|
||||||
if (typeFilter && issue.issue_type !== typeFilter) return false;
|
if (typeFilter && issue.issue_type !== typeFilter) return false;
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
const subject = String(issue.subject ?? "").toLowerCase();
|
return (
|
||||||
const description = String(issue.description ?? "").toLowerCase();
|
issue.subject.toLowerCase().includes(q) ||
|
||||||
const issueType = String(issue.issue_type ?? "").toLowerCase();
|
issue.description.toLowerCase().includes(q) ||
|
||||||
return subject.includes(q) || description.includes(q) || issueType.includes(q);
|
issue.issue_type.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
@ -540,10 +537,10 @@ export function IssuesPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium text-grayScale-600 truncate">
|
<p className="text-sm font-medium text-grayScale-600 truncate">
|
||||||
{issue.subject?.trim() ? issue.subject : "—"}
|
{issue.subject}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-grayScale-400 truncate mt-0.5">
|
<p className="text-xs text-grayScale-400 truncate mt-0.5">
|
||||||
{issue.description?.trim() ? issue.description : "No description"}
|
{issue.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -575,9 +572,6 @@ export function IssuesPage() {
|
||||||
{getStatusConfig(s).label}
|
{getStatusConfig(s).label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
{!STATUSES.includes(issue.status as (typeof STATUSES)[number]) && issue.status ? (
|
|
||||||
<option value={issue.status}>{getStatusConfig(issue.status).label}</option>
|
|
||||||
) : null}
|
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 pointer-events-none opacity-50" />
|
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 pointer-events-none opacity-50" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import { Bell, Mail, MailOpen, Megaphone } from "lucide-react"
|
import { Bell, Loader2, Mail, MailOpen, Megaphone } from "lucide-react"
|
||||||
import { Card, CardContent } from "../../components/ui/card"
|
import { Card, CardContent } from "../../components/ui/card"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
import { Textarea } from "../../components/ui/textarea"
|
import { Textarea } from "../../components/ui/textarea"
|
||||||
import { FileUpload } from "../../components/ui/file-upload"
|
import { FileUpload } from "../../components/ui/file-upload"
|
||||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { getTeamMembers } from "../../api/team.api"
|
import { getTeamMembers } from "../../api/team.api"
|
||||||
import type { TeamMember } from "../../types/team.types"
|
import type { TeamMember } from "../../types/team.types"
|
||||||
|
|
@ -283,7 +282,7 @@ export function CreateNotificationPage() {
|
||||||
>
|
>
|
||||||
{sending ? (
|
{sending ? (
|
||||||
<>
|
<>
|
||||||
<SpinnerIcon className="mr-2 h-3.5 w-3.5" alt="" />
|
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||||
Sending…
|
Sending…
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -348,7 +347,7 @@ export function CreateNotificationPage() {
|
||||||
<div className="max-h-64 space-y-1.5 overflow-y-auto rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-2">
|
<div className="max-h-64 space-y-1.5 overflow-y-auto rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-2">
|
||||||
{recipientsLoading && (
|
{recipientsLoading && (
|
||||||
<div className="flex items-center justify-center py-6 text-xs text-grayScale-400">
|
<div className="flex items-center justify-center py-6 text-xs text-grayScale-400">
|
||||||
<SpinnerIcon className="mr-2 h-4 w-4" alt="" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Loading users…
|
Loading users…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,8 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus, Search, Shield, ShieldCheck, ChevronLeft, ChevronRight,
|
||||||
Search,
|
AlertCircle, Eye, X, Pencil, Check,
|
||||||
Shield,
|
|
||||||
ShieldCheck,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
AlertCircle,
|
|
||||||
Eye,
|
|
||||||
X,
|
|
||||||
Pencil,
|
|
||||||
Check,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Card, CardContent } from "../../components/ui/card"
|
import { Card, CardContent } from "../../components/ui/card"
|
||||||
|
|
@ -22,14 +12,7 @@ import { Textarea } from "../../components/ui/textarea"
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||||
} from "../../components/ui/dialog"
|
} from "../../components/ui/dialog"
|
||||||
import {
|
import { getRoles, getRoleDetail, getAllPermissions, setRolePermissions, updateRole } from "../../api/rbac.api"
|
||||||
getRoles,
|
|
||||||
getRoleDetail,
|
|
||||||
getAllPermissions,
|
|
||||||
setRolePermissions,
|
|
||||||
updateRole,
|
|
||||||
deleteRole,
|
|
||||||
} from "../../api/rbac.api"
|
|
||||||
import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
|
import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
@ -53,11 +36,6 @@ export function RolesListPage() {
|
||||||
const [detailOpen, setDetailOpen] = useState(false)
|
const [detailOpen, setDetailOpen] = useState(false)
|
||||||
const [detailLoading, setDetailLoading] = useState(false)
|
const [detailLoading, setDetailLoading] = useState(false)
|
||||||
|
|
||||||
// Delete modal state
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
|
||||||
const [roleToDelete, setRoleToDelete] = useState<Role | null>(null)
|
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
|
||||||
|
|
||||||
// Role info editing state
|
// Role info editing state
|
||||||
const [editingRole, setEditingRole] = useState(false)
|
const [editingRole, setEditingRole] = useState(false)
|
||||||
const [editName, setEditName] = useState("")
|
const [editName, setEditName] = useState("")
|
||||||
|
|
@ -81,28 +59,27 @@ export function RolesListPage() {
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [query])
|
}, [query])
|
||||||
|
|
||||||
const fetchRoles = useCallback(async () => {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const res = await getRoles({
|
|
||||||
query: debouncedQuery || undefined,
|
|
||||||
page,
|
|
||||||
page_size: pageSize,
|
|
||||||
})
|
|
||||||
setRoles(res.data.data.roles ?? [])
|
|
||||||
setTotal(res.data.data.total ?? 0)
|
|
||||||
} catch {
|
|
||||||
setError("Failed to load roles.")
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [debouncedQuery, page, pageSize])
|
|
||||||
|
|
||||||
// Fetch roles
|
// Fetch roles
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const fetchRoles = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await getRoles({
|
||||||
|
query: debouncedQuery || undefined,
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
})
|
||||||
|
setRoles(res.data.data.roles ?? [])
|
||||||
|
setTotal(res.data.data.total ?? 0)
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load roles.")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
fetchRoles()
|
fetchRoles()
|
||||||
}, [fetchRoles])
|
}, [debouncedQuery, page, pageSize])
|
||||||
|
|
||||||
// Open role detail
|
// Open role detail
|
||||||
const handleViewRole = async (roleId: number) => {
|
const handleViewRole = async (roleId: number) => {
|
||||||
|
|
@ -120,45 +97,6 @@ export function RolesListPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteRoleClick = (role: Role) => {
|
|
||||||
setRoleToDelete(role)
|
|
||||||
setDeleteDialogOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCancelDeleteRole = () => {
|
|
||||||
setDeleteDialogOpen(false)
|
|
||||||
setRoleToDelete(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleConfirmDeleteRole = async () => {
|
|
||||||
if (!roleToDelete) return
|
|
||||||
setDeleteLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await deleteRole(roleToDelete.id)
|
|
||||||
toast.success(res.data.message ?? "Role deleted successfully")
|
|
||||||
|
|
||||||
// Close dialogs if the deleted role is currently opened.
|
|
||||||
if (selectedRole?.id === roleToDelete.id) {
|
|
||||||
setDetailOpen(false)
|
|
||||||
setSelectedRole(null)
|
|
||||||
setEditingPermissions(false)
|
|
||||||
setEditingRole(false)
|
|
||||||
setPermSearch("")
|
|
||||||
}
|
|
||||||
|
|
||||||
setRoleToDelete(null)
|
|
||||||
setDeleteDialogOpen(false)
|
|
||||||
await fetchRoles()
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message =
|
|
||||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
|
||||||
"Failed to delete role."
|
|
||||||
toast.error(message)
|
|
||||||
} finally {
|
|
||||||
setDeleteLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter role info edit mode
|
// Enter role info edit mode
|
||||||
const handleEditRole = () => {
|
const handleEditRole = () => {
|
||||||
if (!selectedRole) return
|
if (!selectedRole) return
|
||||||
|
|
@ -364,7 +302,7 @@ export function RolesListPage() {
|
||||||
{roles.map((role) => (
|
{roles.map((role) => (
|
||||||
<Card
|
<Card
|
||||||
key={role.id}
|
key={role.id}
|
||||||
className="overflow-hidden border border-grayScale-100 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md"
|
className="overflow-hidden shadow-sm transition-shadow hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -374,7 +312,7 @@ export function RolesListPage() {
|
||||||
: "bg-gradient-to-r from-brand-500 to-brand-600",
|
: "bg-gradient-to-r from-brand-500 to-brand-600",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<CardContent className="space-y-4 p-5">
|
<CardContent className="p-5">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div
|
<div
|
||||||
|
|
@ -392,63 +330,32 @@ export function RolesListPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-grayScale-700">
|
<h3 className="text-sm font-semibold text-grayScale-700">{role.name}</h3>
|
||||||
{role.name}
|
<p className="mt-0.5 text-xs text-grayScale-400 line-clamp-1">
|
||||||
</h3>
|
{role.description}
|
||||||
<p className="mt-0.5 text-xs text-grayScale-500 line-clamp-2">
|
|
||||||
{role.description?.trim() || "No description provided for this role."}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
{role.is_system && (
|
||||||
variant={role.is_system ? "warning" : "outline"}
|
<Badge variant="warning" className="shrink-0 text-[10px]">
|
||||||
className="shrink-0 text-[10px]"
|
System
|
||||||
>
|
</Badge>
|
||||||
{role.is_system ? "System" : "Custom"}
|
)}
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 rounded-xl border border-grayScale-100 bg-grayScale-50/70 p-2.5 text-[11px]">
|
<div className="mt-4 flex items-center justify-between">
|
||||||
<div>
|
|
||||||
<p className="text-grayScale-400">Role ID</p>
|
|
||||||
<p className="font-semibold text-grayScale-700">#{role.id}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-grayScale-400">Created</p>
|
|
||||||
<p className="font-semibold text-grayScale-700">
|
|
||||||
{new Date(role.created_at).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-[11px] text-grayScale-400">
|
<span className="text-[11px] text-grayScale-400">
|
||||||
Open details to view permissions
|
Created {new Date(role.created_at).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<Button
|
||||||
{!role.is_system && (
|
variant="outline"
|
||||||
<Button
|
size="sm"
|
||||||
type="button"
|
className="h-8 gap-1.5 text-xs"
|
||||||
variant="destructive"
|
onClick={() => handleViewRole(role.id)}
|
||||||
size="icon"
|
>
|
||||||
className="h-8 w-8"
|
<Eye className="h-3.5 w-3.5" />
|
||||||
onClick={() => handleDeleteRoleClick(role)}
|
View
|
||||||
disabled={deleteLoading}
|
</Button>
|
||||||
aria-label={`Delete role ${role.name}`}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 text-xs"
|
|
||||||
onClick={() => handleViewRole(role.id)}
|
|
||||||
>
|
|
||||||
<Eye className="h-3.5 w-3.5" />
|
|
||||||
View
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -782,55 +689,6 @@ export function RolesListPage() {
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Delete role dialog */}
|
|
||||||
<Dialog
|
|
||||||
open={deleteDialogOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setDeleteDialogOpen(open)
|
|
||||||
if (!open) handleCancelDeleteRole()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-w-sm">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2 text-red-600">
|
|
||||||
<Trash2 className="h-5 w-5" />
|
|
||||||
Delete Role
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Are you sure you want to delete this role? This action cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{roleToDelete && (
|
|
||||||
<div className="rounded-lg bg-red-50 border border-red-100 p-3">
|
|
||||||
<p className="text-sm font-medium text-red-700">{roleToDelete.name}</p>
|
|
||||||
<p className="text-xs text-red-500 mt-0.5">Role #{roleToDelete.id}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleCancelDeleteRole}
|
|
||||||
disabled={deleteLoading}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
className="gap-1.5"
|
|
||||||
disabled={deleteLoading || !roleToDelete}
|
|
||||||
onClick={handleConfirmDeleteRole}
|
|
||||||
>
|
|
||||||
{deleteLoading ? <SpinnerIcon className="h-3.5 w-3.5" /> : <Trash2 className="h-3.5 w-3.5" />}
|
|
||||||
{deleteLoading ? "Deleting..." : "Delete"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,6 @@ export interface GetCoursesResponse {
|
||||||
|
|
||||||
export interface CreateCourseRequest {
|
export interface CreateCourseRequest {
|
||||||
category_id: number
|
category_id: number
|
||||||
sub_category_id?: number | null
|
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
@ -57,496 +56,6 @@ export interface UpdateCourseRequest {
|
||||||
is_active?: boolean
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Exam prep catalog course row (e.g. IELTS / DET cards) */
|
|
||||||
export interface ExamPrepCatalogCourseItem {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
sort_order?: number
|
|
||||||
units_count?: number
|
|
||||||
modules_count?: number
|
|
||||||
lessons_count?: number
|
|
||||||
created_at?: string
|
|
||||||
updated_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateExamPrepCatalogCourseRequest {
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateExamPrepCatalogCourseResponse {
|
|
||||||
message: string
|
|
||||||
data: ExamPrepCatalogCourseItem
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetExamPrepCatalogCoursesResponse {
|
|
||||||
message: string
|
|
||||||
data: {
|
|
||||||
offset: number
|
|
||||||
limit: number
|
|
||||||
total_count: number
|
|
||||||
catalog_courses: ExamPrepCatalogCourseItem[]
|
|
||||||
}
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateExamPrepCatalogCourseRequest {
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
sort_order: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateExamPrepCatalogCourseResponse {
|
|
||||||
message: string
|
|
||||||
data: ExamPrepCatalogCourseItem
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExamPrepCatalogUnitItem {
|
|
||||||
id: number
|
|
||||||
catalog_course_id: number
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
sort_order?: number
|
|
||||||
modules_count?: number
|
|
||||||
lessons_count?: number
|
|
||||||
videos_count?: number
|
|
||||||
practices_count?: number
|
|
||||||
created_at?: string
|
|
||||||
updated_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateExamPrepCatalogUnitRequest {
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateExamPrepCatalogUnitResponse {
|
|
||||||
message: string
|
|
||||||
data: ExamPrepCatalogUnitItem
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateExamPrepCatalogUnitRequest {
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
sort_order: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateExamPrepCatalogUnitResponse {
|
|
||||||
message: string
|
|
||||||
data: ExamPrepCatalogUnitItem
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetExamPrepCatalogUnitsResponse {
|
|
||||||
message: string
|
|
||||||
data: {
|
|
||||||
offset: number
|
|
||||||
limit: number
|
|
||||||
total_count: number
|
|
||||||
units: ExamPrepCatalogUnitItem[]
|
|
||||||
}
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExamPrepUnitModuleItem {
|
|
||||||
id: number
|
|
||||||
unit_id: number
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
icon?: string | null
|
|
||||||
sort_order?: number
|
|
||||||
lessons_count?: number
|
|
||||||
videos_count?: number
|
|
||||||
practices_count?: number
|
|
||||||
created_at?: string
|
|
||||||
updated_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateExamPrepUnitModuleRequest {
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
icon?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateExamPrepUnitModuleResponse {
|
|
||||||
message: string
|
|
||||||
data: ExamPrepUnitModuleItem
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateExamPrepUnitModuleRequest {
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
icon?: string | null
|
|
||||||
sort_order: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateExamPrepUnitModuleResponse {
|
|
||||||
message: string
|
|
||||||
data: ExamPrepUnitModuleItem
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetExamPrepUnitModulesResponse {
|
|
||||||
message: string
|
|
||||||
data: {
|
|
||||||
offset: number
|
|
||||||
limit: number
|
|
||||||
total_count: number
|
|
||||||
modules: ExamPrepUnitModuleItem[]
|
|
||||||
}
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExamPrepModuleLessonItem {
|
|
||||||
id: number
|
|
||||||
unit_module_id: number
|
|
||||||
title: string
|
|
||||||
video_url: string
|
|
||||||
thumbnail?: string | null
|
|
||||||
description?: string | null
|
|
||||||
sort_order?: number
|
|
||||||
created_at?: string
|
|
||||||
updated_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateExamPrepModuleLessonRequest {
|
|
||||||
title: string
|
|
||||||
video_url: string
|
|
||||||
thumbnail?: string | null
|
|
||||||
description?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateExamPrepModuleLessonResponse {
|
|
||||||
message: string
|
|
||||||
data: ExamPrepModuleLessonItem
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateExamPrepModuleLessonRequest {
|
|
||||||
title: string
|
|
||||||
video_url?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
description?: string | null
|
|
||||||
sort_order: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateExamPrepModuleLessonResponse {
|
|
||||||
message: string
|
|
||||||
data: ExamPrepModuleLessonItem
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetExamPrepModuleLessonsResponse {
|
|
||||||
message: string
|
|
||||||
data: {
|
|
||||||
lessons: ExamPrepModuleLessonItem[]
|
|
||||||
total_count: number
|
|
||||||
limit: number
|
|
||||||
offset: number
|
|
||||||
}
|
|
||||||
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)
|
// Legacy Types (deprecated - using SubCourse hierarchy now)
|
||||||
// Keeping for backward compatibility with existing API endpoints
|
// Keeping for backward compatibility with existing API endpoints
|
||||||
|
|
@ -663,13 +172,7 @@ export interface GetModulesResponse {
|
||||||
export interface CreateModuleRequest {
|
export interface CreateModuleRequest {
|
||||||
level_id: number
|
level_id: number
|
||||||
title: string
|
title: string
|
||||||
/** Legacy field kept for backward compatibility. */
|
content: string
|
||||||
content?: string
|
|
||||||
/** Preferred field for module detail text. */
|
|
||||||
description?: string
|
|
||||||
icon_url?: string
|
|
||||||
display_order?: number
|
|
||||||
is_active?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated Use UpdateSubCourseRequest instead */
|
/** @deprecated Use UpdateSubCourseRequest instead */
|
||||||
|
|
@ -689,8 +192,6 @@ export interface UpdateModuleStatusRequest {
|
||||||
export interface SubCourse {
|
export interface SubCourse {
|
||||||
id: number
|
id: number
|
||||||
course_id: number
|
course_id: number
|
||||||
/** Present when derived from course hierarchy rows (levels → modules → sub-modules). */
|
|
||||||
level_id?: number
|
|
||||||
module_id?: number
|
module_id?: number
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
|
|
@ -1200,72 +701,6 @@ export interface HumanLanguageLesson {
|
||||||
practices: LearningPathPractice[]
|
practices: LearningPathPractice[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubModuleLessonDetail {
|
|
||||||
id: number
|
|
||||||
sub_module_id: number
|
|
||||||
display_order: number
|
|
||||||
is_active: boolean
|
|
||||||
created_at: string
|
|
||||||
title: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
teaching_text?: string | null
|
|
||||||
teaching_image_url?: string | null
|
|
||||||
teaching_audio_url?: string | null
|
|
||||||
teaching_video_url?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubModuleLesson {
|
|
||||||
id: number
|
|
||||||
sub_module_id: number
|
|
||||||
display_order: number
|
|
||||||
is_active: boolean
|
|
||||||
created_at: string
|
|
||||||
title: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
teaching_text?: string | null
|
|
||||||
teaching_image_url?: string | null
|
|
||||||
teaching_audio_url?: string | null
|
|
||||||
teaching_video_url?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetSubModuleLessonDetailResponse {
|
|
||||||
message: string
|
|
||||||
data: SubModuleLessonDetail
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateSubModuleLessonRequest {
|
|
||||||
title: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
teaching_text?: string | null
|
|
||||||
teaching_image_url?: string | null
|
|
||||||
teaching_audio_url?: string | null
|
|
||||||
teaching_video_url?: string | null
|
|
||||||
display_order: number
|
|
||||||
is_active: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateSubModuleLessonResponse {
|
|
||||||
message: string
|
|
||||||
data: SubModuleLessonDetail
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetSubModuleLessonsResponse {
|
|
||||||
message: string
|
|
||||||
data: SubModuleLesson[]
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetHumanLanguageLessonsResponse {
|
export interface GetHumanLanguageLessonsResponse {
|
||||||
message: string
|
message: string
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -1279,209 +714,10 @@ export interface GetHumanLanguageLessonsResponse {
|
||||||
metadata: unknown
|
metadata: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Row from GET /course-management/human-language/sub-categories */
|
|
||||||
export interface HumanLanguageSubCategoryListItem {
|
|
||||||
id: number
|
|
||||||
category_id: number
|
|
||||||
category_name: string
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
display_order: number
|
|
||||||
is_active: boolean
|
|
||||||
created_at: string
|
|
||||||
/** Present on some payloads; ignore if unused. */
|
|
||||||
total_count?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetHumanLanguageSubCategoriesResponse {
|
|
||||||
message: string
|
|
||||||
data: {
|
|
||||||
sub_categories: HumanLanguageSubCategoryListItem[]
|
|
||||||
total_count: number
|
|
||||||
}
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Row from GET /course-management/categories/:categoryId/sub-categories */
|
|
||||||
export interface CategorySubCategoryListItem {
|
|
||||||
id: number
|
|
||||||
category_id: number
|
|
||||||
category_name: string
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
display_order: number
|
|
||||||
is_active: boolean
|
|
||||||
created_at: string
|
|
||||||
/** Sometimes echoed per row by the API; safe to ignore. */
|
|
||||||
total_count?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetCategorySubCategoriesResponse {
|
|
||||||
message: string
|
|
||||||
data: {
|
|
||||||
sub_categories: CategorySubCategoryListItem[]
|
|
||||||
total_count: number
|
|
||||||
}
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Row from GET /course-management/sub-categories/:subCategoryId/courses */
|
|
||||||
export interface SubCategoryCourseListItem {
|
|
||||||
id: number
|
|
||||||
category_id: number
|
|
||||||
sub_category_id: number
|
|
||||||
title: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
intro_video_url?: string | null
|
|
||||||
is_active: boolean
|
|
||||||
total_count?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetSubCategoryCoursesResponse {
|
|
||||||
message: string
|
|
||||||
data: {
|
|
||||||
courses: SubCategoryCourseListItem[]
|
|
||||||
total_count: number
|
|
||||||
}
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Row from GET /course-management/courses/:courseId/levels or GET /course-management/levels */
|
|
||||||
export interface CourseLevelRow {
|
|
||||||
id: number
|
|
||||||
course_id: number
|
|
||||||
cefr_level: string
|
|
||||||
display_order: number
|
|
||||||
is_active: boolean
|
|
||||||
created_at: string
|
|
||||||
title: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
total_count?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetCourseLevelsForCourseResponse {
|
|
||||||
message: string
|
|
||||||
data: {
|
|
||||||
levels: CourseLevelRow[]
|
|
||||||
total_count: number
|
|
||||||
}
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetCourseLevelsAllResponse {
|
|
||||||
message: string
|
|
||||||
data: {
|
|
||||||
levels: CourseLevelRow[]
|
|
||||||
total_count: number
|
|
||||||
}
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetCourseLevelByIdResponse {
|
|
||||||
message: string
|
|
||||||
data: CourseLevelRow
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Row from GET /course-management/modules/:moduleId/sub-modules */
|
|
||||||
export interface CourseSubModuleListItem {
|
|
||||||
id: number
|
|
||||||
module_id: number
|
|
||||||
title: string
|
|
||||||
description?: string | null
|
|
||||||
display_order: number
|
|
||||||
is_active: boolean
|
|
||||||
created_at: string
|
|
||||||
legacy_sub_course_id?: number | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
tips?: string | null
|
|
||||||
total_count?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetSubModulesByModuleResponse {
|
|
||||||
message: string
|
|
||||||
data: {
|
|
||||||
sub_modules: CourseSubModuleListItem[]
|
|
||||||
total_count: number
|
|
||||||
}
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Row from GET /course-management/human-language/hierarchy */
|
|
||||||
export interface HumanLanguageHierarchyFlatRow {
|
|
||||||
category_id: number
|
|
||||||
category_name: string
|
|
||||||
sub_category_id?: number | null
|
|
||||||
sub_category_name?: string | null
|
|
||||||
course_id?: number | null
|
|
||||||
course_title?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetHumanLanguageHierarchyFlatResponse {
|
|
||||||
message: string
|
|
||||||
data: HumanLanguageHierarchyFlatRow[]
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Row from GET /course-management/courses/:courseId/hierarchy */
|
|
||||||
export interface CourseHierarchyRow {
|
|
||||||
course_id: number
|
|
||||||
course_title: string
|
|
||||||
level_id?: number | null
|
|
||||||
cefr_level?: string | null
|
|
||||||
level_title?: string | null
|
|
||||||
level_description?: string | null
|
|
||||||
level_thumbnail?: string | null
|
|
||||||
module_id?: number | null
|
|
||||||
module_title?: string | null
|
|
||||||
module_icon_url?: string | null
|
|
||||||
sub_module_id?: number | null
|
|
||||||
sub_module_title?: string | null
|
|
||||||
sub_module_description?: string | null
|
|
||||||
sub_module_thumbnail?: string | null
|
|
||||||
sub_module_tips?: string | null
|
|
||||||
sub_module_display_order?: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetCourseHierarchyResponse {
|
|
||||||
message: string
|
|
||||||
data: CourseHierarchyRow[]
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HumanLanguageSubModule {
|
export interface HumanLanguageSubModule {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
videos: LearningPathVideo[]
|
videos: LearningPathVideo[]
|
||||||
lessons?: {
|
|
||||||
id: number
|
|
||||||
question_set_id: number
|
|
||||||
title: string
|
|
||||||
status: string
|
|
||||||
question_count: number
|
|
||||||
display_order: number
|
|
||||||
intro_video_url?: string | null
|
|
||||||
}[]
|
|
||||||
practices: LearningPathPractice[]
|
practices: LearningPathPractice[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1492,7 +728,6 @@ export interface HumanLanguageModule {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HumanLanguageLevelTree {
|
export interface HumanLanguageLevelTree {
|
||||||
level_id?: number
|
|
||||||
level: string
|
level: string
|
||||||
modules: HumanLanguageModule[]
|
modules: HumanLanguageModule[]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,14 +60,6 @@ export interface CreateRoleResponse {
|
||||||
metadata: unknown
|
metadata: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeleteRoleResponse {
|
|
||||||
message: string
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
// Some backends may include extra fields; keep it optional for compatibility.
|
|
||||||
metadata?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SetRolePermissionsRequest {
|
export interface SetRolePermissionsRequest {
|
||||||
permission_ids: number[]
|
permission_ids: number[]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user